From b5d15652dfa39d56541adb729fa2abd633b48082 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Thu, 19 Mar 2026 08:03:43 +0530 Subject: [PATCH 01/20] Add dynamic client type infrastructure for Asset Hub chains Introduce chain-types.ts with GenericChainClient, AssetHubApi union type, isAssetHubGenesis() and hasReviveApi() type guard. Refactor ClientProvider to instantiate typed DedotClient based on genesis hash (PolkadotAssetHubApi, WestendAssetHubApi, PaseoAssetHubApi) and expose isAssetHub in context. Widen DedotClient to GenericChainClient across downstream consumers (types.ts, parser.ts, hooks). --- components/params/types.ts | 5 ++--- context/client.tsx | 31 ++++++++++++++++++++++++------- hooks/use-chain-token.ts | 5 ++--- hooks/use-pallet-context.ts | 5 ++--- hooks/use-ss58.ts | 5 ++--- lib/chain-types.ts | 29 +++++++++++++++++++++++++++++ lib/parser.ts | 7 +++---- 7 files changed, 64 insertions(+), 23 deletions(-) create mode 100644 lib/chain-types.ts diff --git a/components/params/types.ts b/components/params/types.ts index afe81c4..9468813 100644 --- a/components/params/types.ts +++ b/components/params/types.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import type { DedotClient } from "dedot"; -import { PolkadotApi } from "@dedot/chaintypes"; +import type { GenericChainClient } from "@/lib/chain-types"; import type { PalletContextData } from "@/types/pallet-context"; export interface ParamInputProps { @@ -11,7 +10,7 @@ export interface ParamInputProps { isDisabled?: boolean; isRequired?: boolean; error?: string; - client: DedotClient; + client: GenericChainClient; typeId?: number; value?: any; onChange?: (value: unknown) => void; diff --git a/context/client.tsx b/context/client.tsx index fc7e41b..c7f501a 100644 --- a/context/client.tsx +++ b/context/client.tsx @@ -10,26 +10,31 @@ import { } from "react"; import { DedotClient, WsProvider } from "dedot"; import type { PolkadotApi } from "@dedot/chaintypes"; +import type { PolkadotAssetHubApi, WestendAssetHubApi, PaseoAssetHubApi } from "@dedot/chaintypes"; import { useChain } from "@luno-kit/react"; +import { isAssetHubGenesis, type GenericChainClient } from "@/lib/chain-types"; const DEFAULT_RPC = "wss://rpc.polkadot.io"; export interface ClientContextValue { - client: DedotClient | null; + client: GenericChainClient | null; loading: boolean; + isAssetHub: boolean; } const ClientContext = createContext({ client: null, loading: true, + isAssetHub: false, }); export const useClient = () => useContext(ClientContext); export const ClientProvider = ({ children }: { children: ReactNode }) => { - const [client, setClient] = useState | null>(null); + const [client, setClient] = useState(null); const [loading, setLoading] = useState(true); - const clientRef = useRef | null>(null); + const [isAssetHub, setIsAssetHub] = useState(false); + const clientRef = useRef(null); const { chain } = useChain(); const rpcUrl = chain?.rpcUrls?.webSocket?.[0] ?? DEFAULT_RPC; @@ -58,15 +63,27 @@ export const ClientProvider = ({ children }: { children: ReactNode }) => { if (cancelled) return; try { - const newClient = await DedotClient.new( - new WsProvider(rpcUrl) - ); + const genesisHash = chain?.genesisHash?.toLowerCase() ?? ""; + const isHub = isAssetHubGenesis(genesisHash); + + let newClient: GenericChainClient; + if (genesisHash === "0x68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f") { + newClient = await DedotClient.new(new WsProvider(rpcUrl)); + } else if (genesisHash === "0x67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9") { + newClient = await DedotClient.new(new WsProvider(rpcUrl)); + } else if (genesisHash === "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2") { + newClient = await DedotClient.new(new WsProvider(rpcUrl)); + } else { + newClient = await DedotClient.new(new WsProvider(rpcUrl)); + } + if (cancelled) { newClient.disconnect().catch(() => {}); return; } clientRef.current = newClient; setClient(newClient); + setIsAssetHub(isHub); } catch (error) { console.error("Error connecting to chain:", error); } finally { @@ -82,7 +99,7 @@ export const ClientProvider = ({ children }: { children: ReactNode }) => { }, [rpcUrl]); return ( - + {children} ); diff --git a/hooks/use-chain-token.ts b/hooks/use-chain-token.ts index 6c32c0b..ad97763 100644 --- a/hooks/use-chain-token.ts +++ b/hooks/use-chain-token.ts @@ -1,8 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import type { DedotClient } from "dedot"; -import type { PolkadotApi } from "@dedot/chaintypes"; +import type { GenericChainClient } from "@/lib/chain-types"; import { getDenominations, type Denomination } from "@/lib/denominations"; export interface ChainTokenInfo { @@ -14,7 +13,7 @@ export interface ChainTokenInfo { } export function useChainToken( - client: DedotClient | null + client: GenericChainClient | null ): ChainTokenInfo { const [info, setInfo] = useState>({ symbol: "DOT", diff --git a/hooks/use-pallet-context.ts b/hooks/use-pallet-context.ts index 6523ac6..01cb8dc 100644 --- a/hooks/use-pallet-context.ts +++ b/hooks/use-pallet-context.ts @@ -1,8 +1,7 @@ "use client"; import { useState, useEffect, useRef, useCallback } from "react"; -import type { DedotClient } from "dedot"; -import type { PolkadotApi } from "@dedot/chaintypes"; +import type { GenericChainClient } from "@/lib/chain-types"; import { useChain } from "@luno-kit/react"; import type { PalletContextData, ContextGroup } from "@/types/pallet-context"; import { networkFromGenesisHash } from "@/types/pallet-context"; @@ -19,7 +18,7 @@ interface PalletContextResult { const contextCache = new Map(); export function usePalletContext( - client: DedotClient | null, + client: GenericChainClient | null, palletName: string | undefined ): PalletContextResult { const [context, setContext] = useState(null); diff --git a/hooks/use-ss58.ts b/hooks/use-ss58.ts index 302a1d6..0511094 100644 --- a/hooks/use-ss58.ts +++ b/hooks/use-ss58.ts @@ -1,8 +1,7 @@ "use client"; import { useMemo, useCallback } from "react"; -import { DedotClient } from "dedot"; -import type { PolkadotApi } from "@dedot/chaintypes"; +import type { GenericChainClient } from "@/lib/chain-types"; import { encodeAddress, decodeAddress } from "dedot/utils"; interface UseSS58Result { @@ -13,7 +12,7 @@ interface UseSS58Result { } export function useSS58( - client: DedotClient | null + client: GenericChainClient | null ): UseSS58Result { const ss58Prefix = useMemo(() => { if (!client) return 42; // Generic Substrate default diff --git a/lib/chain-types.ts b/lib/chain-types.ts new file mode 100644 index 0000000..c25138f --- /dev/null +++ b/lib/chain-types.ts @@ -0,0 +1,29 @@ +import type { PolkadotApi, PolkadotAssetHubApi, WestendAssetHubApi, PaseoAssetHubApi } from "@dedot/chaintypes"; +import type { DedotClient } from "dedot"; + +export type AssetHubApi = PolkadotAssetHubApi | WestendAssetHubApi | PaseoAssetHubApi; +export type AnyChainApi = PolkadotApi | AssetHubApi; + +/** + * Generic client type used throughout the app for metadata/registry/tx/consts access. + * DedotClient is invariant on its type parameter, so a union like + * `DedotClient` is not assignable from specific clients. + * We use `DedotClient` as the shared type — all generic DedotClient features + * (metadata, registry, tx, consts, chainSpec) work identically regardless of the + * chain API type parameter. + */ +export type GenericChainClient = DedotClient; // eslint-disable-line + +const ASSET_HUB_GENESIS = new Set([ + "0x68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f", // Polkadot Asset Hub + "0x67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9", // Westend Asset Hub + "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", // Paseo Asset Hub +]); + +export function isAssetHubGenesis(genesisHash: string): boolean { + return ASSET_HUB_GENESIS.has(genesisHash.toLowerCase()); +} + +export function hasReviveApi(client: GenericChainClient): client is DedotClient { + return typeof (client as any).call?.reviveApi?.instantiate === "function"; +} diff --git a/lib/parser.ts b/lib/parser.ts index 11f6d4c..deda2ae 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -1,5 +1,4 @@ -import { PolkadotApi } from "@dedot/chaintypes"; -import { DedotClient } from "dedot"; +import type { GenericChainClient } from "@/lib/chain-types"; import { Metadata, TypeDef } from "dedot/codecs"; import { assert, stringCamelCase } from "dedot/utils"; @@ -35,7 +34,7 @@ export type ClientMethod = { }; export function createMethodOptions( - client: DedotClient, + client: GenericChainClient, sectionIndex: number ): { text: string; value: number }[] | null { const pallet = client.metadata.latest.pallets.find( @@ -68,7 +67,7 @@ export function createMethodOptions( * } * */ -export function getArgType(client: DedotClient, typeId: number) { +export function getArgType(client: GenericChainClient, typeId: number) { const type = client.registry.findType(typeId); return getTypeDetails(type.typeDef); } From 102cc728bb913881e4328ce4dda2db86d632c498 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Thu, 19 Mar 2026 08:03:56 +0530 Subject: [PATCH 02/20] Add gas estimation hook, fee display, and struct external value sync New useGasEstimation hook performs reviveApi.instantiate dry-runs with 10% safety buffer on weight/storage. Only populates estimate state on successful dry-runs to prevent consumers from acting on failed results. Add fee-display.ts for formatting planck amounts and weight values. Extract shared compile logic into compile-client.ts. Fix Struct component to sync local state from external value prop (needed for gas estimation auto-fill) and pass values to child inputs. Widen BuilderFormValues index signature to support object values. --- app/builder/page.tsx | 2 +- components/params/inputs/struct.tsx | 12 +++ hooks/use-gas-estimation.ts | 148 ++++++++++++++++++++++++++++ lib/compile-client.ts | 59 +++++++++++ lib/fee-display.ts | 42 ++++++++ 5 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 hooks/use-gas-estimation.ts create mode 100644 lib/compile-client.ts create mode 100644 lib/fee-display.ts diff --git a/app/builder/page.tsx b/app/builder/page.tsx index 1ceda22..f4f997b 100644 --- a/app/builder/page.tsx +++ b/app/builder/page.tsx @@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "@/context/client"; export interface BuilderFormValues { section: string; method: string; - [key: string]: string; + [key: string]: any; } function BuilderContent() { diff --git a/components/params/inputs/struct.tsx b/components/params/inputs/struct.tsx index 0cf2fb1..850fdc0 100644 --- a/components/params/inputs/struct.tsx +++ b/components/params/inputs/struct.tsx @@ -30,8 +30,19 @@ export function Struct({ error: externalError, onChange, fields, + value: externalValue, }: StructProps) { const [values, setValues] = React.useState>({}); + + // Sync local values from external value prop (e.g. gas estimation auto-fill) + React.useEffect(() => { + if (externalValue && typeof externalValue === "object" && !Array.isArray(externalValue)) { + setValues((prev) => { + if (JSON.stringify(prev) !== JSON.stringify(externalValue)) return externalValue; + return prev; + }); + } + }, [externalValue]); const [validationError, setValidationError] = React.useState(null); // Get list of required field names @@ -84,6 +95,7 @@ export function Struct({ description: field.description, isDisabled: isDisabled, isRequired: field.required, + value: values[field.name], onChange: (value: any) => handleFieldChange(field.name, value), })} diff --git a/hooks/use-gas-estimation.ts b/hooks/use-gas-estimation.ts new file mode 100644 index 0000000..aca4512 --- /dev/null +++ b/hooks/use-gas-estimation.ts @@ -0,0 +1,148 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { DedotClient } from "dedot"; +import type { AssetHubApi, GenericChainClient } from "@/lib/chain-types"; +import { hasReviveApi } from "@/lib/chain-types"; + +export interface GasEstimationResult { + estimating: boolean; + weightRequired: { refTime: bigint; proofSize: bigint } | null; + storageDeposit: { type: "Charge" | "Refund"; value: bigint } | null; + gasConsumed: bigint | null; + deployedAddress: string | null; + error: string | null; + estimate: () => Promise; +} + +const BUFFER_PERCENT = BigInt(10); + +function applyBuffer(value: bigint): bigint { + return value + (value * BUFFER_PERCENT) / BigInt(100); +} + +export function useGasEstimation( + client: GenericChainClient | null, + origin: string, + value: bigint, + code: string | Uint8Array, + data: string, + salt?: string +): GasEstimationResult { + const [estimating, setEstimating] = useState(false); + const [weightRequired, setWeightRequired] = useState<{ + refTime: bigint; + proofSize: bigint; + } | null>(null); + const [storageDeposit, setStorageDeposit] = useState<{ + type: "Charge" | "Refund"; + value: bigint; + } | null>(null); + const [gasConsumed, setGasConsumed] = useState(null); + const [deployedAddress, setDeployedAddress] = useState(null); + const [error, setError] = useState(null); + + const estimate = useCallback(async () => { + if (!client) { + setError("No client connected"); + return; + } + + if (!hasReviveApi(client)) { + setError("Connected chain does not support Revive API. Switch to an Asset Hub chain."); + return; + } + + if (!origin) { + setError("No account connected. Connect a wallet first."); + return; + } + + if (!code) { + setError("No bytecode provided. Compile a contract first."); + return; + } + + setEstimating(true); + setError(null); + setWeightRequired(null); + setStorageDeposit(null); + setGasConsumed(null); + setDeployedAddress(null); + + try { + const assetHubClient = client as DedotClient; + + // Prepare code as Upload variant — ensure hex string type + const codeHex = typeof code === "string" ? code : `0x${Buffer.from(code).toString("hex")}`; + const codeParam = { type: "Upload" as const, value: codeHex as `0x${string}` }; + + // Salt: undefined means no salt + const saltParam = salt && salt !== "" ? (salt as `0x${string}`) : undefined; + + const result = await assetHubClient.call.reviveApi.instantiate( + origin, + value, + undefined, // gas_limit: let the runtime estimate + undefined, // storage_deposit_limit: let the runtime estimate + codeParam, + (data || "0x") as `0x${string}`, + saltParam + ); + + // Check if the dry-run succeeded before populating estimate state + if (result.result.isOk) { + // Apply 10% buffer to weight + const bufferedWeight = { + refTime: applyBuffer(result.weightRequired.refTime), + proofSize: applyBuffer(result.weightRequired.proofSize), + }; + setWeightRequired(bufferedWeight); + + // Storage deposit with buffer (only for Charge) + const sd = result.storageDeposit; + if (sd.type === "Charge") { + setStorageDeposit({ + type: "Charge", + value: applyBuffer(sd.value), + }); + } else { + setStorageDeposit({ + type: "Refund", + value: sd.value, + }); + } + + setGasConsumed(result.gasConsumed); + + const instantiateResult = result.result.value; + if (instantiateResult.addr) { + const addr = instantiateResult.addr; + setDeployedAddress(typeof addr === "string" ? addr : String(addr)); + } + } else { + // Dry-run failed — keep estimate state null so consumers don't act on it + const dispatchError = result.result.err; + const errorMsg = dispatchError + ? `Dry-run failed: ${JSON.stringify(dispatchError)}` + : "Dry-run failed with unknown error"; + setError(errorMsg); + } + } catch (e) { + const message = e instanceof Error ? e.message : "Gas estimation failed"; + setError(message); + } finally { + setEstimating(false); + } + }, [client, origin, value, code, data, salt]); + + return { + estimating, + weightRequired, + storageDeposit, + gasConsumed, + deployedAddress, + error, + estimate, + }; +} diff --git a/lib/compile-client.ts b/lib/compile-client.ts new file mode 100644 index 0000000..fcc648a --- /dev/null +++ b/lib/compile-client.ts @@ -0,0 +1,59 @@ +import type { CompilationError } from "@/lib/contract-store"; + +export interface CompileResult { + success: boolean; + contracts: Record | null; + contractNames: string[]; + errors: CompilationError[]; + warnings: CompilationError[]; +} + +/** + * Compile Solidity source code via the /api/compile endpoint. + * Shared between the builder's ContractCode component and the Studio's CompilePanel. + */ +export async function compileSolidity( + source: string, + mode: "evm" | "pvm" +): Promise { + const res = await fetch("/api/compile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source, mode }), + }); + + const data = await res.json(); + + if (!res.ok) { + return { + success: false, + contracts: null, + contractNames: [], + errors: data.errors || [ + { + message: data.error || "Compilation failed", + severity: "error", + }, + ], + warnings: [], + }; + } + + if (!data.success) { + return { + success: false, + contracts: null, + contractNames: [], + errors: data.errors || [{ message: "Compilation failed", severity: "error" }], + warnings: data.warnings || [], + }; + } + + return { + success: true, + contracts: data.contracts || {}, + contractNames: data.contractNames || [], + errors: data.errors || [], + warnings: data.warnings || [], + }; +} diff --git a/lib/fee-display.ts b/lib/fee-display.ts new file mode 100644 index 0000000..79be7b7 --- /dev/null +++ b/lib/fee-display.ts @@ -0,0 +1,42 @@ +import { fromPlanck, getDenominations } from "@/lib/denominations"; + +/** + * Format a planck amount as a human-readable fee string in the chain's native token. + * E.g., "0.0123 DOT" or "123.45 WND" + */ +export function formatFee( + planckValue: bigint, + symbol: string, + decimals: number +): string { + const denoms = getDenominations(symbol, decimals); + // Use the main denomination (first one = full token) + const mainDenom = denoms[0]; + const formatted = fromPlanck(planckValue.toString(), mainDenom); + return `${formatted} ${symbol}`; +} + +/** + * Format a weight value for display. + */ +export function formatWeight(weight: { refTime: bigint; proofSize: bigint }): string { + const refTime = formatCompact(weight.refTime); + const proofSize = formatCompact(weight.proofSize); + return `refTime: ${refTime}, proofSize: ${proofSize}`; +} + +function formatCompact(value: bigint): string { + if (value >= BigInt(1_000_000_000)) { + const billions = Number(value) / 1_000_000_000; + return `${billions.toFixed(2)}B`; + } + if (value >= BigInt(1_000_000)) { + const millions = Number(value) / 1_000_000; + return `${millions.toFixed(2)}M`; + } + if (value >= BigInt(1_000)) { + const thousands = Number(value) / 1_000; + return `${thousands.toFixed(2)}K`; + } + return value.toString(); +} From 8be9f6bef7dbc5736068887f8b16b0a6d220d82b Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Thu, 19 Mar 2026 08:04:15 +0530 Subject: [PATCH 03/20] Add Contract Studio page with three-panel resizable layout New /studio route with CompilePanel (left), EditorPanel (center, Monaco + output), and DeployPanel (right, constructor form + gas estimation + deploy + transaction log). ContractProvider context replaces the global singleton for Studio state to avoid mount/unmount reset conflicts between panels. Constructor form validates all fields before encoding (matching builder behavior), resets state on ABI changes, and provides hex fallback input with validation for unsupported constructor types. Deploy panel clears stale constructor data on contract switches, logs estimation failures to the transaction log, and validates hex inputs at the form boundary. Upload handler routes .sol files to the editor and validates .bin/.hex uploads for even-length hex. --- app/studio/layout.tsx | 16 + app/studio/page.tsx | 18 ++ components/studio/chain-required-banner.tsx | 20 ++ components/studio/compile-panel.tsx | 287 ++++++++++++++++++ components/studio/constructor-form.tsx | 205 +++++++++++++ components/studio/contract-studio.tsx | 68 +++++ components/studio/deploy-panel.tsx | 319 ++++++++++++++++++++ components/studio/editor-panel.tsx | 116 +++++++ components/studio/transaction-log.tsx | 45 +++ components/ui/resizable.tsx | 51 ++++ components/ui/scroll-area.tsx | 48 +++ context/contract-provider.tsx | 69 +++++ package.json | 4 +- yarn.lock | 12 +- 14 files changed, 1270 insertions(+), 8 deletions(-) create mode 100644 app/studio/layout.tsx create mode 100644 app/studio/page.tsx create mode 100644 components/studio/chain-required-banner.tsx create mode 100644 components/studio/compile-panel.tsx create mode 100644 components/studio/constructor-form.tsx create mode 100644 components/studio/contract-studio.tsx create mode 100644 components/studio/deploy-panel.tsx create mode 100644 components/studio/editor-panel.tsx create mode 100644 components/studio/transaction-log.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 context/contract-provider.tsx diff --git a/app/studio/layout.tsx b/app/studio/layout.tsx new file mode 100644 index 0000000..876e916 --- /dev/null +++ b/app/studio/layout.tsx @@ -0,0 +1,16 @@ +import { NavBar } from "@/components/layout/site-header"; +import { SiteFooter } from "@/components/layout/site-footer"; + +interface StudioLayoutProps { + children: React.ReactNode; +} + +export default function StudioLayout({ children }: StudioLayoutProps) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/app/studio/page.tsx b/app/studio/page.tsx new file mode 100644 index 0000000..0046a93 --- /dev/null +++ b/app/studio/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import React from "react"; +import { ClientProvider } from "@/context/client"; +import { ContractProvider } from "@/context/contract-provider"; +import { ContractStudio } from "@/components/studio/contract-studio"; + +export default function StudioPage() { + return ( +
+ + + + + +
+ ); +} diff --git a/components/studio/chain-required-banner.tsx b/components/studio/chain-required-banner.tsx new file mode 100644 index 0000000..8e745ad --- /dev/null +++ b/components/studio/chain-required-banner.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { AlertCircle } from "lucide-react"; + +export function ChainRequiredBanner() { + return ( +
+ +
+

+ Asset Hub chain required +

+

+ Contract Studio requires an Asset Hub chain (Polkadot Asset Hub, Westend Asset Hub, or + Paseo Asset Hub). Use the chain selector in the top bar to switch. +

+
+
+ ); +} diff --git a/components/studio/compile-panel.tsx b/components/studio/compile-panel.tsx new file mode 100644 index 0000000..d134737 --- /dev/null +++ b/components/studio/compile-panel.tsx @@ -0,0 +1,287 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ModeToggle } from "@/components/params/shared/mode-toggle"; +import { useContractContext } from "@/context/contract-provider"; +import { compileSolidity } from "@/lib/compile-client"; +import { Upload, Play, Loader2, Check, AlertCircle, FileCode } from "lucide-react"; + +interface CompilePanelProps { + source: string; + onSourceChange: (source: string) => void; +} + +export function CompilePanel({ source, onSourceChange }: CompilePanelProps) { + const { setCompilation, resetCompilation, selectContract, ...compilation } = + useContractContext(); + const [compileTarget, setCompileTarget] = useState<"evm" | "pvm">("pvm"); + const [isCompiling, setIsCompiling] = useState(false); + const [fileName, setFileName] = useState(null); + const [abiFileName, setAbiFileName] = useState(null); + const bytecodeInputRef = useRef(null); + const abiInputRef = useRef(null); + + const handleCompile = async () => { + if (!source.trim()) return; + setIsCompiling(true); + setCompilation({ isCompiling: true, errors: [], warnings: [] }); + + try { + const result = await compileSolidity(source, compileTarget); + + if (!result.success) { + resetCompilation(); + setCompilation({ + isCompiling: false, + errors: result.errors, + warnings: result.warnings, + }); + return; + } + + const contractNames = result.contractNames; + const firstContract = contractNames[0] || null; + const firstData = firstContract ? result.contracts?.[firstContract] : null; + + setCompilation({ + allContracts: result.contracts, + contractName: firstContract, + abi: firstData?.abi || null, + bytecode: firstData?.bytecode || null, + contractNames, + errors: result.errors, + warnings: result.warnings, + isCompiling: false, + mode: compileTarget, + }); + } catch (err) { + resetCompilation(); + setCompilation({ + isCompiling: false, + errors: [ + { + message: err instanceof Error ? err.message : "Network error", + severity: "error", + }, + ], + }); + } finally { + setIsCompiling(false); + } + }; + + const handleContractSelect = (name: string) => { + selectContract(name); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFileName(file.name); + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result as string; + + // .sol files → load into editor + if (file.name.endsWith(".sol")) { + onSourceChange(content); + return; + } + + // .bin / .hex → parse as hex bytecode + const raw = content.trim().replace(/\s+/g, ""); + const normalized = raw.startsWith("0x") || raw.startsWith("0X") + ? `0x${raw.slice(2).toLowerCase()}` + : `0x${raw.toLowerCase()}`; + if (!/^0x[0-9a-f]*$/.test(normalized) || normalized.length % 2 !== 0) { + setCompilation({ + bytecode: null, + allContracts: null, + contractNames: [], + errors: [{ message: "Invalid bytecode file: must be valid hex with even length", severity: "error" }], + }); + return; + } + setCompilation({ + bytecode: normalized.slice(2), // store without 0x prefix + errors: [], + }); + }; + reader.readAsText(file); + }; + + const handleAbiUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setAbiFileName(file.name); + const reader = new FileReader(); + reader.onload = () => { + try { + const abi = JSON.parse(reader.result as string); + setCompilation({ + abi: Array.isArray(abi) ? abi : abi.abi || [], + contractName: file.name.replace(/\.json$/, ""), + }); + } catch { + setCompilation({ + abi: null, + contractName: null, + bytecode: null, + allContracts: null, + contractNames: [], + errors: [{ message: "Failed to parse ABI JSON", severity: "error" }], + }); + } + }; + reader.readAsText(file); + }; + + const byteCount = compilation.bytecode + ? Math.floor(compilation.bytecode.length / 2) + : 0; + const abiCount = compilation.abi?.length ?? 0; + + return ( +
+ {/* Compile section */} +
+

+ Compile +

+ setCompileTarget(m as "evm" | "pvm")} + disabled={isCompiling} + /> + +
+ + {/* Contract selector */} + {compilation.contractNames.length > 1 && ( +
+ Contract + +
+ )} + + {/* Info */} + {compilation.bytecode && ( +
+
+ + + {compilation.contractName || "Contract"} + +
+
+

{byteCount.toLocaleString()} bytes

+ {abiCount > 0 && ( +

ABI: {abiCount} entries

+ )} +
+ {!isCompiling && compilation.errors.length === 0 && ( + + + Compiled + + )} +
+ )} + + {/* Upload section */} +
+

+ Upload +

+ + + + +
+ + {/* Errors */} + {compilation.errors.length > 0 && ( +
+ {compilation.errors.map((err, i) => ( +
+ + {err.formattedMessage || err.message} +
+ ))} +
+ )} +
+ ); +} diff --git a/components/studio/constructor-form.tsx b/components/studio/constructor-form.tsx new file mode 100644 index 0000000..0e8e0df --- /dev/null +++ b/components/studio/constructor-form.tsx @@ -0,0 +1,205 @@ +"use client"; + +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + getConstructorInputs, + allConstructorTypesSupported, + encodeConstructorArgs, + solidityTypeLabel, + isSupportedType, +} from "@/lib/abi-encoder"; +import { AlertTriangle, Info } from "lucide-react"; + +interface ConstructorFormProps { + abi: any[] | null; + onEncodedChange: (hex: string | undefined) => void; + /** When true, the form shows unsupported-type info without a "use hex below" message, + * because the parent will render its own hex fallback input. */ + hasHexFallback?: boolean; +} + +export function ConstructorForm({ abi, onEncodedChange, hasHexFallback }: ConstructorFormProps) { + const [argValues, setArgValues] = useState>({}); + const isEncodingRef = useRef(false); + + const constructorInputs = abi ? getConstructorInputs(abi) : []; + const hasConstructor = constructorInputs.length > 0; + const typesSupported = abi ? allConstructorTypesSupported(abi) : false; + + // Reset field values when the ABI changes (contract switch / recompile) + useEffect(() => { + setArgValues({}); + }, [abi]); + + // Auto-emit "0x" when ABI exists but constructor has no arguments + useEffect(() => { + if (abi && !hasConstructor) { + onEncodedChange("0x"); + } + }, [abi, hasConstructor]); // eslint-disable-line react-hooks/exhaustive-deps + + // Validate a single constructor arg value against its Solidity type. + // Mirrors the builder's validateArg in contract-constructor.tsx. + const validateArg = useCallback((type: string, value: any): boolean => { + if (type === "bool") return value !== undefined && value !== null; + if (type === "string") return value !== undefined && value !== null; + const v = String(value ?? ""); + if (v === "") return false; + if (type === "address") return /^0x[0-9a-fA-F]{40}$/.test(v); + if (type.startsWith("uint") || type.startsWith("int")) { + try { BigInt(v); return true; } catch { return false; } + } + if (type === "bytes") return /^0x([0-9a-fA-F]{2})*$/.test(v); + if (/^bytes\d+$/.test(type)) { + const n = parseInt(type.slice(5)); + return /^0x([0-9a-fA-F]{2})*$/.test(v) && (v.length - 2) / 2 <= n; + } + return true; + }, []); + + const encodeAndEmit = useCallback( + (values: Record) => { + if (!abi || !hasConstructor || !typesSupported) return; + + const inputs = getConstructorInputs(abi); + const hasAnyValue = inputs.some((input) => { + const v = values[input.name]; + if (v === undefined || v === null) return false; + if (input.type === "bool" || input.type === "string") return true; + return v !== ""; + }); + + if (!hasAnyValue) { + onEncodedChange(undefined); + return; + } + + // Only encode if every field passes validation — don't emit partial/bogus data + const allValid = inputs.every((input) => validateArg(input.type, values[input.name])); + if (!allValid) { + onEncodedChange(undefined); + return; + } + + try { + isEncodingRef.current = true; + const encoded = encodeConstructorArgs(abi, values); + onEncodedChange(encoded); + } catch { + onEncodedChange(undefined); + } finally { + isEncodingRef.current = false; + } + }, + [abi, hasConstructor, typesSupported, onEncodedChange, validateArg] + ); + + // Initialize bool args to false and encode immediately so the parent + // receives valid calldata without requiring a user interaction first + useEffect(() => { + if (!abi || !hasConstructor || !typesSupported) return; + const inputs = getConstructorInputs(abi); + const defaults: Record = {}; + for (const input of inputs) { + if (input.type === "bool") defaults[input.name] = false; + } + if (Object.keys(defaults).length > 0) { + setArgValues(defaults); + encodeAndEmit(defaults); + } + }, [abi, hasConstructor, typesSupported, encodeAndEmit]); + + const handleArgChange = (argName: string, value: any) => { + const next = { ...argValues, [argName]: value }; + setArgValues(next); + encodeAndEmit(next); + }; + + if (!abi) { + return ( +
+ +

+ Compile a contract to see constructor arguments. +

+
+ ); + } + + if (!hasConstructor) { + return ( +
+ +

+ No constructor arguments. +

+
+ ); + } + + if (!typesSupported) { + return ( +
+ +

+ Constructor has complex types (arrays/tuples). + {hasHexFallback + ? " Use the hex input below to enter ABI-encoded constructor data." + : " Compile externally with Remix and provide the encoded data."} +

+
+ ); + } + + return ( +
+ {constructorInputs.map((input) => { + const value = argValues[input.name] ?? ""; + + if (input.type === "bool") { + return ( +
+ + handleArgChange(input.name, checked)} + /> +
+ ); + } + + let placeholder = ""; + if (input.type === "address") placeholder = "0x..."; + else if (input.type.startsWith("uint") || input.type.startsWith("int")) placeholder = "0"; + else if (input.type === "string") placeholder = "Enter text"; + else if (input.type === "bytes") placeholder = "0x"; + + return ( +
+ + handleArgChange(input.name, e.target.value)} + className="font-mono text-xs h-8" + placeholder={placeholder} + /> +
+ ); + })} +
+ ); +} diff --git a/components/studio/contract-studio.tsx b/components/studio/contract-studio.tsx new file mode 100644 index 0000000..5267e91 --- /dev/null +++ b/components/studio/contract-studio.tsx @@ -0,0 +1,68 @@ +"use client"; + +import React, { useState } from "react"; +import { + ResizablePanel, + ResizablePanelGroup, + ResizableHandle, +} from "@/components/ui/resizable"; +import { useClient } from "@/context/client"; +import { Skeleton } from "@/components/ui/skeleton"; +import { CompilePanel } from "./compile-panel"; +import { EditorPanel } from "./editor-panel"; +import { DeployPanel } from "./deploy-panel"; + +const DEFAULT_SOLIDITY = `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract MyToken { + string public name; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + + constructor(string memory _name, uint256 _initialSupply) { + name = _name; + totalSupply = _initialSupply; + balanceOf[msg.sender] = _initialSupply; + } +}`; + +export function ContractStudio() { + const { client, loading } = useClient(); + const [source, setSource] = useState(DEFAULT_SOLIDITY); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + {/* Left: Compile panel */} + +
+ +
+
+ + + {/* Center: Editor + Output */} + + + + + + {/* Right: Deploy panel */} + +
+ +
+
+
+
+ ); +} diff --git a/components/studio/deploy-panel.tsx b/components/studio/deploy-panel.tsx new file mode 100644 index 0000000..326b8cc --- /dev/null +++ b/components/studio/deploy-panel.tsx @@ -0,0 +1,319 @@ +"use client"; + +import React, { useState, useCallback, useId } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { useClient } from "@/context/client"; +import { useContractContext } from "@/context/contract-provider"; +import { useGasEstimation } from "@/hooks/use-gas-estimation"; +import { useChainToken } from "@/hooks/use-chain-token"; +import { formatFee, formatWeight } from "@/lib/fee-display"; +import { ConstructorForm } from "./constructor-form"; +import { TransactionLog, LogEntry } from "./transaction-log"; +import { ChainRequiredBanner } from "./chain-required-banner"; +import { hasReviveApi } from "@/lib/chain-types"; +import { allConstructorTypesSupported, getConstructorInputs } from "@/lib/abi-encoder"; +import { useAccount, useSendTransaction } from "@luno-kit/react"; +import { Loader2, Zap, Rocket } from "lucide-react"; + +export function DeployPanel() { + const { client } = useClient(); + const { abi, bytecode } = useContractContext(); + const { account } = useAccount(); + const { sendTransactionAsync, isPending } = useSendTransaction(); + const { symbol, decimals } = useChainToken(client); + + const [constructorData, setConstructorData] = useState(); + const [hexDataInput, setHexDataInput] = useState(""); + const [valueInput, setValueInput] = useState("0"); + const [saltInput, setSaltInput] = useState(""); + const [logEntries, setLogEntries] = useState([]); + + // Detect whether the constructor has unsupported types (needs hex fallback) + const constructorInputs = abi ? getConstructorInputs(abi) : []; + const hasConstructor = constructorInputs.length > 0; + const needsHexFallback = abi ? hasConstructor && !allConstructorTypesSupported(abi) : false; + const [hexDataError, setHexDataError] = useState(null); + + // Reset constructor-related state when the ABI changes (contract switch / recompile) + React.useEffect(() => { + setConstructorData(undefined); + setHexDataInput(""); + setHexDataError(null); + }, [abi]); + + const logId = useId(); + const logCounterRef = React.useRef(0); + + const addLog = useCallback( + (type: LogEntry["type"], message: string) => { + const counter = logCounterRef.current++; + setLogEntries((prev) => [ + ...prev, + { + id: `${logId}-${Date.now()}-${counter}`, + type, + message, + timestamp: new Date(), + }, + ]); + }, + [logId] + ); + + const code = bytecode ? `0x${bytecode}` : ""; + const valueBigInt = (() => { + try { + return BigInt(valueInput || "0"); + } catch { + return BigInt(0); + } + })(); + + // Validate hex fallback input + const isValidHex = (v: string) => + !v || v === "0x" || (/^0x[0-9a-fA-F]*$/.test(v) && v.length % 2 === 0); + + const handleHexDataChange = (e: React.ChangeEvent) => { + const val = e.target.value.trim(); + setHexDataInput(val); + if (!val || val === "0x") { + setHexDataError(null); + } else if (!/^0x[0-9a-fA-F]*$/.test(val)) { + setHexDataError("Must be a hex string with 0x prefix"); + } else if (val.length % 2 !== 0) { + setHexDataError("Hex string must have even length"); + } else { + setHexDataError(null); + } + }; + + // When hex fallback is active, use the raw hex input; otherwise use the encoded form data + const effectiveData = needsHexFallback + ? (hexDataInput || "0x") + : (constructorData || "0x"); + + const gasEstimation = useGasEstimation( + client, + account?.address || "", + valueBigInt, + code, + effectiveData, + saltInput || undefined + ); + + const isAssetHub = client ? hasReviveApi(client) : false; + + const handleEstimate = async () => { + addLog("info", "Estimating gas..."); + await gasEstimation.estimate(); + // State updates from the hook won't be visible until next render. + // The effect below handles logging success/failure after React re-renders. + }; + + // Log estimation results after React applies the hook's state updates + React.useEffect(() => { + if (gasEstimation.weightRequired && !gasEstimation.error) { + addLog( + "success", + `Estimated: ${formatWeight(gasEstimation.weightRequired)}` + ); + if (gasEstimation.storageDeposit) { + const sd = gasEstimation.storageDeposit; + addLog( + "info", + `Storage: ${sd.type} ${formatFee(sd.value, symbol, decimals)}` + ); + } + } + if (gasEstimation.error && !gasEstimation.estimating) { + addLog("error", `Estimation failed: ${gasEstimation.error}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gasEstimation.weightRequired, gasEstimation.error]); + + const handleDeploy = async () => { + if (!client || !account || !bytecode) return; + + if (!hasReviveApi(client)) { + addLog("error", "Chain does not support Revive API"); + return; + } + + addLog("pending", "Deploying contract..."); + + try { + const assetHubClient = client as any; + const salt = saltInput ? saltInput : undefined; + + const extrinsic = assetHubClient.tx.revive.instantiateWithCode( + valueBigInt, + gasEstimation.weightRequired + ? { + refTime: gasEstimation.weightRequired.refTime, + proofSize: gasEstimation.weightRequired.proofSize, + } + : { refTime: BigInt(0), proofSize: BigInt(0) }, + gasEstimation.storageDeposit?.type === "Charge" + ? gasEstimation.storageDeposit.value + : BigInt(0), + `0x${bytecode}`, + effectiveData, + salt + ); + + const receipt = await sendTransactionAsync({ extrinsic }); + + addLog("success", `Deployed in block: ${receipt.blockHash}`); + if (gasEstimation.deployedAddress) { + addLog("success", `Address: ${gasEstimation.deployedAddress}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Deploy failed"; + addLog("error", message); + } + }; + + if (!isAssetHub && client) { + return ( +
+

+ Deploy +

+ +
+ ); + } + + return ( +
+ {/* Constructor */} +
+

+ Constructor +

+ + {needsHexFallback && ( +
+ + + {hexDataError && ( +

{hexDataError}

+ )} +
+ )} +
+ + + + {/* Config */} +
+

+ Config +

+
+ + setValueInput(e.target.value)} + className="font-mono text-xs h-8" + placeholder="0" + /> +
+
+ + setSaltInput(e.target.value)} + className="font-mono text-xs h-8" + placeholder="0x... or leave empty" + /> +
+
+ + {/* Gas estimation */} +
+ + + {gasEstimation.weightRequired && ( +
+

+ Weight: {formatWeight(gasEstimation.weightRequired)} +

+ {gasEstimation.storageDeposit && ( +

+ Storage ({gasEstimation.storageDeposit.type}):{" "} + {formatFee(gasEstimation.storageDeposit.value, symbol, decimals)} +

+ )} + {gasEstimation.gasConsumed !== null && ( +

Gas consumed: {gasEstimation.gasConsumed.toString()}

+ )} +
+ )} + + {gasEstimation.error && ( +

{gasEstimation.error}

+ )} +
+ + + + {/* Deploy */} + + + {/* Transaction log */} +
+

+ Log +

+
+ +
+
+
+ ); +} diff --git a/components/studio/editor-panel.tsx b/components/studio/editor-panel.tsx new file mode 100644 index 0000000..9aa9405 --- /dev/null +++ b/components/studio/editor-panel.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React from "react"; +import dynamic from "next/dynamic"; +import { useTheme } from "next-themes"; +import { useContractContext } from "@/context/contract-provider"; +import { + ResizablePanel, + ResizablePanelGroup, + ResizableHandle, +} from "@/components/ui/resizable"; +import { Check, AlertCircle, AlertTriangle } from "lucide-react"; + +const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { + ssr: false, +}); + +interface EditorPanelProps { + source: string; + onSourceChange: (source: string) => void; +} + +export function EditorPanel({ source, onSourceChange }: EditorPanelProps) { + const { resolvedTheme } = useTheme(); + const compilation = useContractContext(); + + const hasOutput = + compilation.errors.length > 0 || + compilation.warnings.length > 0 || + compilation.bytecode; + + return ( + + +
+ onSourceChange(val || "")} + options={{ + minimap: { enabled: true }, + fontSize: 14, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + tabSize: 4, + padding: { top: 8, bottom: 8 }, + }} + /> +
+
+ + +
+ {!hasOutput && ( +

+ Output will appear here after compilation +

+ )} + + {compilation.isCompiling && ( +

Compiling...

+ )} + + {compilation.bytecode && + compilation.errors.length === 0 && + !compilation.isCompiling && ( +
+ + Compiled successfully + {compilation.contractName && ( + + ({compilation.contractName}) + + )} +
+ )} + + {compilation.errors.length > 0 && ( +
+ {compilation.errors.map((err, i) => ( +
+ +
+                    {err.formattedMessage || err.message}
+                  
+
+ ))} +
+ )} + + {compilation.warnings.length > 0 && ( +
+ {compilation.warnings.map((warn, i) => ( +
+ +
+                    {warn.formattedMessage || warn.message}
+                  
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/components/studio/transaction-log.tsx b/components/studio/transaction-log.tsx new file mode 100644 index 0000000..bd2f63c --- /dev/null +++ b/components/studio/transaction-log.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Check, X, Loader2, Info } from "lucide-react"; + +export interface LogEntry { + id: string; + type: "info" | "success" | "error" | "pending"; + message: string; + timestamp: Date; +} + +interface TransactionLogProps { + entries: LogEntry[]; +} + +const ICONS = { + info: , + success: , + error: , + pending: , +}; + +export function TransactionLog({ entries }: TransactionLogProps) { + return ( + +
+ {entries.length === 0 && ( +

+ No activity yet +

+ )} + {entries.map((entry) => ( +
+ {ICONS[entry.type]} + + {entry.message} + +
+ ))} +
+
+ ); +} diff --git a/components/ui/resizable.tsx b/components/ui/resizable.tsx new file mode 100644 index 0000000..1d2209b --- /dev/null +++ b/components/ui/resizable.tsx @@ -0,0 +1,51 @@ +"use client" + +import { GripVertical } from "lucide-react" +import { Group, Panel, Separator } from "react-resizable-panels" +import type { GroupProps } from "react-resizable-panels" + +import { cn } from "@/lib/utils" + +type Direction = "horizontal" | "vertical" + +const ResizablePanelGroup = ({ + className, + direction, + ...props +}: Omit & { direction?: Direction }) => ( + +) + +const ResizablePanel = Panel + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean +}) => ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+) + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/context/contract-provider.tsx b/context/contract-provider.tsx new file mode 100644 index 0000000..52b9a2c --- /dev/null +++ b/context/contract-provider.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import type { ContractCompilationState, CompilationError } from "@/lib/contract-store"; + +interface ContractContextValue extends ContractCompilationState { + setCompilation: (update: Partial) => void; + resetCompilation: () => void; + selectContract: (name: string) => void; +} + +const INITIAL_STATE: ContractCompilationState = { + abi: null, + contractName: null, + bytecode: null, + contractNames: [], + allContracts: null, + errors: [], + warnings: [], + isCompiling: false, + mode: "pvm", +}; + +const ContractContext = createContext({ + ...INITIAL_STATE, + setCompilation: () => {}, + resetCompilation: () => {}, + selectContract: () => {}, +}); + +export const useContractContext = () => useContext(ContractContext); + +export function ContractProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ ...INITIAL_STATE }); + + const setCompilation = useCallback((update: Partial) => { + setState((prev) => ({ ...prev, ...update })); + }, []); + + const resetCompilation = useCallback(() => { + setState({ ...INITIAL_STATE }); + }, []); + + const selectContract = useCallback((name: string) => { + setState((prev) => { + if (!prev.allContracts || !prev.allContracts[name]) return prev; + const contract = prev.allContracts[name]; + return { + ...prev, + contractName: name, + abi: contract.abi, + bytecode: contract.bytecode, + }; + }); + }, []); + + return ( + + {children} + + ); +} diff --git a/package.json b/package.json index 1d285eb..5b965ca 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", - "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slider": "^1.2.2", @@ -74,7 +74,7 @@ "react-hook-form": "^7.54.1", "react-icons": "^5.3.0", "react-markdown": "^9.0.1", - "react-resizable-panels": "^2.1.7", + "react-resizable-panels": "^4.7.3", "react-wrap-balancer": "latest", "recharts": "2.15.0", "solc": "^0.8.34", diff --git a/yarn.lock b/yarn.lock index e7a45c9..a29a209 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2176,9 +2176,9 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-controllable-state" "1.2.2" -"@radix-ui/react-scroll-area@^1.2.10", "@radix-ui/react-scroll-area@^1.2.2": +"@radix-ui/react-scroll-area@^1.2.10": version "1.2.10" - resolved "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz#e4fd3b4a79bb77bec1a52f0c8f26d8f3f1ca4b22" integrity sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A== dependencies: "@radix-ui/number" "1.1.1" @@ -8271,10 +8271,10 @@ react-remove-scroll@^2.6.3, react-remove-scroll@^2.7.1: use-callback-ref "^1.3.3" use-sidecar "^1.1.3" -react-resizable-panels@^2.1.7: - version "2.1.9" - resolved "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz" - integrity sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ== +react-resizable-panels@^4.7.3: + version "4.7.3" + resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-4.7.3.tgz#4040aa0f5c5c4cc4bb685cb69973601ccda3b014" + integrity sha512-PYcYMLtvJD+Pr0TQNeMvddcnLOwUa/Yb4iNwU7ThNLlHaQYEEC9MIBWHaBGODzYuXIkPRZ/OWe5sbzG1Rzq5ew== react-smooth@^4.0.0: version "4.0.4" From a0fdf4abe8ef43df5a78b970c3ec0679d38404a9 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Thu, 19 Mar 2026 08:04:27 +0530 Subject: [PATCH 04/20] Add gas estimation to builder and fix info pane object serialization Show Estimate Gas button and Open in Contract Studio link when pallet=Revive, method=instantiate_with_code. Auto-fill weight_limit (using metadata-derived field names) and storage_deposit_limit (Charge only) from dry-run results. Fix information-pane argValuesKey to use JSON.stringify for object values so weight struct changes trigger re-encoding. Refactor contract-code.tsx to use shared compile-client.ts. --- components/builder/extrinsic-builder.tsx | 143 ++++++++++++++++++++- components/builder/information-pane.tsx | 11 +- components/params/inputs/contract-code.tsx | 41 ++---- 3 files changed, 156 insertions(+), 39 deletions(-) diff --git a/components/builder/extrinsic-builder.tsx b/components/builder/extrinsic-builder.tsx index 89683de..b9f6ee6 100644 --- a/components/builder/extrinsic-builder.tsx +++ b/components/builder/extrinsic-builder.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; -import { DedotClient } from "dedot"; -import type { PolkadotApi } from "@dedot/chaintypes"; +import Link from "next/link"; +import type { GenericChainClient } from "@/lib/chain-types"; +import { hasReviveApi } from "@/lib/chain-types"; import { createMethodOptions, createSectionOptions, @@ -17,6 +18,7 @@ import { import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { GenericTxCall } from "dedot/types"; import { stringCamelCase } from "dedot/utils"; +import { assert } from "dedot/utils"; import { Dialog, DialogContent, @@ -34,9 +36,13 @@ import { BuilderFormValues } from "@/app/builder/page"; import { useAccount, useSendTransaction } from "@luno-kit/react"; import { toast } from "sonner"; import { usePalletContext } from "@/hooks/use-pallet-context"; +import { useGasEstimation } from "@/hooks/use-gas-estimation"; +import { useChainToken } from "@/hooks/use-chain-token"; +import { formatFee, formatWeight } from "@/lib/fee-display"; +import { Loader2, Zap, ArrowRight } from "lucide-react"; interface ExtrinsicBuilderProps { - client: DedotClient; + client: GenericChainClient; tx: GenericTxCall | null; onTxChange: (tx: GenericTxCall) => void; builderForm: UseFormReturn; @@ -66,6 +72,77 @@ const ExtrinsicBuilder: React.FC = ({ const { context: palletContext, isLoading: isContextLoading } = usePalletContext(client, palletName); + const { symbol, decimals } = useChainToken(client); + + // Detect Revive instantiate_with_code for gas estimation + const isReviveInstantiate = + palletName === "Revive" && methodName === "instantiate_with_code"; + + // Watch form values for gas estimation inputs + const codeValue = builderForm.watch("code") || ""; + const dataValue = builderForm.watch("data") || ""; + const valueValue = builderForm.watch("value") || "0"; + const saltValue = builderForm.watch("salt") || ""; + + const valueBigInt = (() => { + try { + return BigInt(valueValue || "0"); + } catch { + return BigInt(0); + } + })(); + + const gasEstimation = useGasEstimation( + isReviveInstantiate ? client : null, + account?.address || "", + valueBigInt, + codeValue, + dataValue, + saltValue || undefined + ); + + // Auto-fill weight and storage deposit from gas estimation + const handleEstimateGas = async () => { + await gasEstimation.estimate(); + }; + + useEffect(() => { + if (!isReviveInstantiate || !gasEstimation.weightRequired || !tx) return; + + // Resolve weight field names from metadata + const weightField = tx.meta?.fields?.find((f) => f.name === "weight_limit"); + if (weightField) { + try { + const weightType = client.registry.findType(weightField.typeId); + const { typeDef } = weightType; + if (typeDef.type === "Struct" && typeDef.value.fields.length >= 2) { + const [field0, field1] = typeDef.value.fields; + const name0 = String(field0.name); + const name1 = String(field1.name); + builderForm.setValue("weight_limit", { + [name0]: String(gasEstimation.weightRequired.refTime), + [name1]: String(gasEstimation.weightRequired.proofSize), + }); + } + } catch { + // Fallback: use camelCase names + builderForm.setValue("weight_limit", { + refTime: String(gasEstimation.weightRequired.refTime), + proofSize: String(gasEstimation.weightRequired.proofSize), + }); + } + } + + // Auto-fill storage deposit only for Charge + if (gasEstimation.storageDeposit?.type === "Charge") { + builderForm.setValue( + "storage_deposit_limit", + String(gasEstimation.storageDeposit.value) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gasEstimation.weightRequired, gasEstimation.storageDeposit]); + useEffect(() => { const section = builderForm.watch("section"); if (section) { @@ -286,6 +363,66 @@ const ExtrinsicBuilder: React.FC = ({ }} /> ))} + {/* Gas estimation for Revive instantiate_with_code */} + {isReviveInstantiate && ( +
+
+ + + Open in Contract Studio + + +
+ + {gasEstimation.weightRequired && ( +
+

+ Weight: {formatWeight(gasEstimation.weightRequired)} +

+ {gasEstimation.storageDeposit && ( +

+ Storage ({gasEstimation.storageDeposit.type}):{" "} + {formatFee( + gasEstimation.storageDeposit.value, + symbol, + decimals + )} +

+ )} +
+ )} + + {gasEstimation.error && ( +

+ {gasEstimation.error} +

+ )} +
+ )} +
+ + {/* Contract selector */} + {compilation.contractNames.length > 1 && ( +
+ Contract + +
+ )} + + {/* Contract info card */} + {compilation.bytecode && ( +
+
+ + + {compilation.contractName || "Contract"} + +
+
+

{byteCount.toLocaleString()} bytes

+ {abiCount > 0 &&

ABI: {abiCount} entries

} +
+ {!isCompiling && compilation.errors.length === 0 && compilation.bytecodeSource === "compile" && ( + + + Compiled + + )} + {compilation.bytecodeSource === "upload" && ( + + + Uploaded + + )} +
+ )} + + {/* Upload section */} +
+ + Upload + + + + + +
+ + {/* Errors */} + {compilation.errors.length > 0 && ( +
+ {compilation.errors.map((err, i) => ( +
+ + + {err.formattedMessage || err.message} + +
+ ))} +
+ )} +
+ ); +} diff --git a/components/studio/deploy-section.tsx b/components/studio/deploy-section.tsx new file mode 100644 index 0000000..5c25572 --- /dev/null +++ b/components/studio/deploy-section.tsx @@ -0,0 +1,405 @@ +"use client"; + +import React, { useState, useCallback, useId } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useClient } from "@/context/client"; +import { useContractContext } from "@/context/contract-provider"; +import { useStudio } from "@/context/studio-provider"; +import { useGasEstimation } from "@/hooks/use-gas-estimation"; +import { useChainToken } from "@/hooks/use-chain-token"; +import { formatFee, formatWeight } from "@/lib/fee-display"; +import { ConstructorForm } from "./constructor-form"; +import { TransactionLog, LogEntry } from "./transaction-log"; +import { ChainRequiredBanner } from "./chain-required-banner"; +import { hasReviveApi } from "@/lib/chain-types"; +import { + allConstructorTypesSupported, + getConstructorInputs, +} from "@/lib/abi-encoder"; +import { useAccount, useSendTransaction } from "@luno-kit/react"; +import { + Loader2, + Zap, + Rocket, + AlertTriangle, + Info, +} from "lucide-react"; + +export function DeploySection() { + const { client } = useClient(); + const { abi, bytecode, mode, bytecodeSource } = useContractContext(); + const { isDirtySinceCompile } = useStudio(); + const { account } = useAccount(); + const { sendTransactionAsync, isPending } = useSendTransaction(); + const { symbol, decimals } = useChainToken(client); + + const [constructorData, setConstructorData] = useState(); + const [hexDataInput, setHexDataInput] = useState(""); + const [valueInput, setValueInput] = useState("0"); + const [saltInput, setSaltInput] = useState(""); + const [logEntries, setLogEntries] = useState([]); + + // Detect whether constructor has unsupported types (needs hex fallback) + const constructorInputs = abi ? getConstructorInputs(abi) : []; + const hasConstructor = constructorInputs.length > 0; + const needsHexFallback = abi + ? hasConstructor && !allConstructorTypesSupported(abi) + : false; + const [hexDataError, setHexDataError] = useState(null); + + // No-ABI deploy path: when bytecode exists but abi is null + const noAbiMode = bytecode !== null && abi === null; + + // Reset constructor-related state when the ABI changes + React.useEffect(() => { + setConstructorData(undefined); + setHexDataInput(""); + setHexDataError(null); + }, [abi]); + + const logId = useId(); + const logCounterRef = React.useRef(0); + + const addLog = useCallback( + (type: LogEntry["type"], message: string) => { + const counter = logCounterRef.current++; + setLogEntries((prev) => [ + ...prev, + { + id: `${logId}-${Date.now()}-${counter}`, + type, + message, + timestamp: new Date(), + }, + ]); + }, + [logId] + ); + + const code = bytecode ? `0x${bytecode}` : ""; + const valueBigInt = (() => { + try { + return BigInt(valueInput || "0"); + } catch { + return BigInt(0); + } + })(); + + const handleHexDataChange = (e: React.ChangeEvent) => { + const val = e.target.value.trim(); + setHexDataInput(val); + if (!val || val === "0x") { + setHexDataError(null); + } else if (!/^0x[0-9a-fA-F]*$/.test(val)) { + setHexDataError("Must be a hex string with 0x prefix"); + } else if (val.length % 2 !== 0) { + setHexDataError("Hex string must have even length"); + } else { + setHexDataError(null); + } + }; + + // Effective data: noAbiMode or hex fallback → raw hex; otherwise → encoded form data + const effectiveData = + noAbiMode || needsHexFallback + ? hexDataInput || "0x" + : constructorData || "0x"; + + const gasEstimation = useGasEstimation( + client, + account?.address || "", + valueBigInt, + code, + effectiveData, + saltInput || undefined + ); + + const isAssetHub = client ? hasReviveApi(client) : false; + + // --- Deploy gate logic --- + let deployEnabled = false; + let deployWarning: string | null = null; + + if (bytecodeSource === "compile") { + if (isDirtySinceCompile) { + deployWarning = "Sources changed since last compile. Recompile first."; + } else if (mode === "evm") { + deployWarning = + "EVM artifacts cannot be deployed on-chain. Switch to PVM and recompile."; + } else if (mode === "pvm" && bytecode) { + deployEnabled = true; + } + } else if (bytecodeSource === "upload") { + deployEnabled = !!bytecode; + } + // bytecodeSource === null → deployEnabled stays false + + const handleEstimate = async () => { + addLog("info", "Estimating gas..."); + await gasEstimation.estimate(); + }; + + React.useEffect(() => { + if (gasEstimation.weightRequired && !gasEstimation.error) { + addLog( + "success", + `Estimated: ${formatWeight(gasEstimation.weightRequired)}` + ); + if (gasEstimation.storageDeposit) { + const sd = gasEstimation.storageDeposit; + addLog( + "info", + `Storage: ${sd.type} ${formatFee(sd.value, symbol, decimals)}` + ); + } + } + if (gasEstimation.error && !gasEstimation.estimating) { + addLog("error", `Estimation failed: ${gasEstimation.error}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gasEstimation.weightRequired, gasEstimation.error]); + + const handleDeploy = async () => { + if (!client || !account || !bytecode) return; + + if (!hasReviveApi(client)) { + addLog("error", "Chain does not support Revive API"); + return; + } + + addLog("pending", "Deploying contract..."); + + try { + const assetHubClient = client as any; + const salt = saltInput ? saltInput : undefined; + + const extrinsic = assetHubClient.tx.revive.instantiateWithCode( + valueBigInt, + gasEstimation.weightRequired + ? { + refTime: gasEstimation.weightRequired.refTime, + proofSize: gasEstimation.weightRequired.proofSize, + } + : { refTime: BigInt(0), proofSize: BigInt(0) }, + gasEstimation.storageDeposit?.type === "Charge" + ? gasEstimation.storageDeposit.value + : BigInt(0), + `0x${bytecode}`, + effectiveData, + salt + ); + + const receipt = await sendTransactionAsync({ extrinsic }); + + addLog("success", `Deployed in block: ${receipt.blockHash}`); + if (gasEstimation.deployedAddress) { + addLog("success", `Address: ${gasEstimation.deployedAddress}`); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Deploy failed"; + addLog("error", message); + } + }; + + if (!isAssetHub && client) { + return ; + } + + return ( +
+ {/* Deploy gate warnings */} + {deployWarning && ( +
+ +

+ {deployWarning} +

+
+ )} + + {/* Upload info note */} + {bytecodeSource === "upload" && bytecode && ( +
+ +

+ Uploaded bytecode will be deployed as-is via Revive. If this is EVM + bytecode (not PVM), the dry-run will fail. +

+
+ )} + + {/* Unverified ABI warning */} + {bytecodeSource === "upload" && abi && ( +
+ +

+ Uploaded ABI is not verified against the bytecode. Ensure they are + from the same contract. +

+
+ )} + + {/* Constructor */} +
+

+ Constructor +

+ {noAbiMode ? ( +
+ + + {hexDataError && ( +

{hexDataError}

+ )} +
+ ) : ( + <> + + {needsHexFallback && ( +
+ + + {hexDataError && ( +

{hexDataError}

+ )} +
+ )} + + )} +
+ + {/* Config */} +
+

+ Config +

+
+ + setValueInput(e.target.value)} + className="font-mono text-xs h-8" + placeholder="0" + /> +
+
+ + setSaltInput(e.target.value)} + className="font-mono text-xs h-8" + placeholder="0x... or leave empty" + /> +
+
+ + {/* Gas estimation */} +
+ + + {gasEstimation.weightRequired && ( +
+

Weight: {formatWeight(gasEstimation.weightRequired)}

+ {gasEstimation.storageDeposit && ( +

+ Storage ({gasEstimation.storageDeposit.type}):{" "} + {formatFee( + gasEstimation.storageDeposit.value, + symbol, + decimals + )} +

+ )} + {gasEstimation.gasConsumed !== null && ( +

Gas consumed: {gasEstimation.gasConsumed.toString()}

+ )} +
+ )} + + {gasEstimation.error && ( +

{gasEstimation.error}

+ )} +
+ + {/* Deploy */} + + + {/* Transaction log */} +
+

+ Log +

+
+ +
+
+
+ ); +} diff --git a/components/studio/editor-area.tsx b/components/studio/editor-area.tsx new file mode 100644 index 0000000..8558dd2 --- /dev/null +++ b/components/studio/editor-area.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React from "react"; +import dynamic from "next/dynamic"; +import { useTheme } from "next-themes"; +import { useStudio } from "@/context/studio-provider"; +import { EditorTabs } from "./editor-tabs"; +import { OutputPanel } from "./output-panel"; + +const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { + ssr: false, +}); + +export function EditorArea() { + const { resolvedTheme } = useTheme(); + const { state, dispatch, activeFile } = useStudio(); + + const handleEditorChange = (value: string | undefined) => { + if (!activeFile) return; + dispatch({ + type: "UPDATE_FILE_CONTENT", + fileId: activeFile.id, + content: value || "", + }); + }; + + return ( +
+ {/* Tabs */} + + + {/* Monaco editor */} +
+ {activeFile ? ( + + ) : ( +
+ Open a file to start editing +
+ )} +
+ + {/* Output panel */} + dispatch({ type: "TOGGLE_OUTPUT" })} + /> +
+ ); +} diff --git a/components/studio/editor-tabs.tsx b/components/studio/editor-tabs.tsx new file mode 100644 index 0000000..cfc47fc --- /dev/null +++ b/components/studio/editor-tabs.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import { useStudio } from "@/context/studio-provider"; +import { X, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function EditorTabs() { + const { state, dispatch, isDirtySinceCompile } = useStudio(); + + const handleCreateFile = () => { + let name = "Untitled.sol"; + let counter = 1; + while (Object.values(state.files).some((f) => f.name === name)) { + name = `Untitled${counter}.sol`; + counter++; + } + dispatch({ type: "CREATE_FILE", name, content: "" }); + }; + + return ( +
+ {state.openTabs.map((tab) => { + const file = state.files[tab.fileId]; + if (!file) return null; + const isActive = state.activeTabId === tab.fileId; + + return ( +
dispatch({ type: "SET_ACTIVE_TAB", fileId: tab.fileId })} + > + {file.name} + {isDirtySinceCompile && ( + + )} + +
+ ); + })} + +
+ ); +} diff --git a/components/studio/file-explorer.tsx b/components/studio/file-explorer.tsx new file mode 100644 index 0000000..18ba964 --- /dev/null +++ b/components/studio/file-explorer.tsx @@ -0,0 +1,163 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { useStudio } from "@/context/studio-provider"; +import { FileTreeItem } from "./file-tree-item"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Search, Plus, Upload, Info } from "lucide-react"; + +export function FileExplorer() { + const { state, dispatch } = useStudio(); + const [searchQuery, setSearchQuery] = useState(""); + const fileInputRef = useRef(null); + + const files = Object.values(state.files).sort((a, b) => + a.name.localeCompare(b.name) + ); + + const filtered = searchQuery + ? files.filter((f) => + f.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : files; + + const canDelete = Object.keys(state.files).length > 1; + + const handleCreateFile = () => { + // Generate unique name + let name = "Untitled.sol"; + let counter = 1; + while (Object.values(state.files).some((f) => f.name === name)) { + name = `Untitled${counter}.sol`; + counter++; + } + dispatch({ type: "CREATE_FILE", name, content: "" }); + }; + + const handleUploadSol = (e: React.ChangeEvent) => { + const uploadedFiles = e.target.files; + if (!uploadedFiles) return; + + const imports: Array<{ name: string; content: string }> = []; + let remaining = uploadedFiles.length; + + Array.from(uploadedFiles).forEach((file) => { + if (!file.name.endsWith(".sol")) { + remaining--; + if (remaining === 0 && imports.length > 0) { + dispatch({ type: "IMPORT_FILES", files: imports }); + } + return; + } + const reader = new FileReader(); + reader.onload = () => { + imports.push({ name: file.name, content: reader.result as string }); + remaining--; + if (remaining === 0 && imports.length > 0) { + dispatch({ type: "IMPORT_FILES", files: imports }); + } + }; + reader.readAsText(file); + }); + + // Reset input so the same file can be uploaded again + e.target.value = ""; + }; + + const handleFileClick = (fileId: string) => { + dispatch({ type: "OPEN_TAB", fileId }); + dispatch({ type: "SET_ACTIVE_TAB", fileId }); + }; + + const handleRename = (fileId: string, newName: string): boolean => { + const before = state.files[fileId]?.name; + dispatch({ type: "RENAME_FILE", fileId, newName }); + // Check if rename was accepted (reducer rejects duplicates silently) + // Since dispatch is sync-ish, we check the name collision ourselves + const nameExists = Object.values(state.files).some( + (f) => f.id !== fileId && f.name === newName + ); + return !nameExists; + }; + + return ( +
+ {/* Header */} +
+ + Files + +
+ +
+
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search files..." + className="h-7 text-xs pl-7" + /> +
+
+ + {/* File tree */} +
+ {filtered.map((file) => ( + handleFileClick(file.id)} + onRename={(newName) => handleRename(file.id, newName)} + onDelete={() => dispatch({ type: "DELETE_FILE", fileId: file.id })} + canDelete={canDelete} + /> + ))} + {filtered.length === 0 && searchQuery && ( +

+ No matching files +

+ )} +
+ + {/* Actions */} +
+ + + +
+
+ ); +} diff --git a/components/studio/file-tree-item.tsx b/components/studio/file-tree-item.tsx new file mode 100644 index 0000000..965f3e4 --- /dev/null +++ b/components/studio/file-tree-item.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { FileCode, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface FileTreeItemProps { + name: string; + isActive: boolean; + onClick: () => void; + onRename: (newName: string) => boolean; // returns false if rejected (duplicate) + onDelete: () => void; + canDelete: boolean; +} + +export function FileTreeItem({ + name, + isActive, + onClick, + onRename, + onDelete, + canDelete, +}: FileTreeItemProps) { + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(name); + const [renameError, setRenameError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (isRenaming && inputRef.current) { + inputRef.current.focus(); + // Select filename without extension + const dotIndex = renameValue.lastIndexOf("."); + inputRef.current.setSelectionRange(0, dotIndex > 0 ? dotIndex : renameValue.length); + } + }, [isRenaming]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleRenameSubmit = () => { + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === name) { + setIsRenaming(false); + setRenameError(null); + return; + } + // Ensure .sol extension + const finalName = trimmed.endsWith(".sol") ? trimmed : `${trimmed}.sol`; + const accepted = onRename(finalName); + if (accepted) { + setIsRenaming(false); + setRenameError(null); + } else { + setRenameError("Name already exists"); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleRenameSubmit(); + if (e.key === "Escape") { + setIsRenaming(false); + setRenameValue(name); + setRenameError(null); + } + }; + + if (isRenaming) { + return ( +
+ { + setRenameValue(e.target.value); + setRenameError(null); + }} + onBlur={handleRenameSubmit} + onKeyDown={handleKeyDown} + className={cn( + "w-full text-xs bg-background border rounded px-1.5 py-0.5 outline-none", + renameError ? "border-red-500" : "border-primary" + )} + /> + {renameError && ( +

{renameError}

+ )} +
+ ); + } + + return ( +
+ + {name} +
+ + {canDelete && ( + + )} +
+
+ ); +} diff --git a/components/studio/output-panel.tsx b/components/studio/output-panel.tsx new file mode 100644 index 0000000..2aa8b52 --- /dev/null +++ b/components/studio/output-panel.tsx @@ -0,0 +1,133 @@ +"use client"; + +import React from "react"; +import { useContractContext } from "@/context/contract-provider"; +import { Check, AlertCircle, AlertTriangle, ChevronDown, ChevronRight, Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface OutputPanelProps { + visible: boolean; + onToggle: () => void; +} + +export function OutputPanel({ visible, onToggle }: OutputPanelProps) { + const compilation = useContractContext(); + + const hasOutput = + compilation.errors.length > 0 || + compilation.warnings.length > 0 || + compilation.bytecode || + compilation.isCompiling; + + const errorCount = compilation.errors.length; + const warningCount = compilation.warnings.length; + + return ( +
+ {/* Toggle header */} + + + {/* Content */} + {visible && ( +
+ {!hasOutput && ( +

+ Output will appear here after compilation +

+ )} + + {compilation.isCompiling && ( +

Compiling...

+ )} + + {compilation.bytecode && + compilation.errors.length === 0 && + !compilation.isCompiling && + compilation.bytecodeSource === "compile" && ( +
+ + Compiled successfully + {compilation.contractName && ( + + ({compilation.contractName}) + + )} +
+ )} + + {compilation.errors.length > 0 && ( +
+ {compilation.errors.map((err, i) => ( +
+ +
+                    {err.formattedMessage || err.message}
+                  
+
+ ))} +
+ )} + + {compilation.warnings.length > 0 && ( +
+ {compilation.warnings.map((warn, i) => ( +
+ +
+                    {warn.formattedMessage || warn.message}
+                  
+
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/components/studio/right-sidebar.tsx b/components/studio/right-sidebar.tsx new file mode 100644 index 0000000..7d130d5 --- /dev/null +++ b/components/studio/right-sidebar.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { CompilerSection } from "./compiler-section"; +import { DeploySection } from "./deploy-section"; + +export function RightSidebar() { + return ( +
+ + + + Compile + + + + + + + + + Deploy + + + + + + +
+ ); +} diff --git a/components/studio/status-bar.tsx b/components/studio/status-bar.tsx new file mode 100644 index 0000000..e4a7205 --- /dev/null +++ b/components/studio/status-bar.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React from "react"; +import { useAccount, useChain } from "@luno-kit/react"; +import { useContractContext } from "@/context/contract-provider"; +import { useStudio } from "@/context/studio-provider"; +import { Check, Loader2, AlertCircle, Circle } from "lucide-react"; + +function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function StatusBar() { + const { chain } = useChain(); + const { account } = useAccount(); + const compilation = useContractContext(); + const { isDirtySinceCompile } = useStudio(); + + const chainName = chain?.name ?? "No chain"; + const address = account?.address ?? ""; + + // Artifact mode badge — shows the mode of the LAST successful compile, NOT the target toggle + const artifactMode = compilation.mode; + + return ( +
+ {/* Left: Chain + Address */} +
+
+ + {chainName} +
+ {address && ( + + {truncateAddress(address)} + + )} +
+ + {/* Center: Artifact mode badge */} +
+ {artifactMode && ( + + {artifactMode} + + )} +
+ + {/* Right: Compile status + dirty indicator */} +
+ {isDirtySinceCompile && compilation.bytecode && ( + + Modified + + )} + {compilation.isCompiling && ( + + + Compiling + + )} + {!compilation.isCompiling && + compilation.bytecode && + compilation.errors.length === 0 && + compilation.bytecodeSource === "compile" && ( + + + Compiled + + )} + {!compilation.isCompiling && compilation.errors.length > 0 && ( + + + {compilation.errors.length} error + {compilation.errors.length > 1 ? "s" : ""} + + )} +
+
+ ); +} diff --git a/components/studio/studio-layout.tsx b/components/studio/studio-layout.tsx new file mode 100644 index 0000000..d6341ab --- /dev/null +++ b/components/studio/studio-layout.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import { + ResizablePanel, + ResizablePanelGroup, + ResizableHandle, +} from "@/components/ui/resizable"; +import { useClient } from "@/context/client"; +import { Skeleton } from "@/components/ui/skeleton"; +import { FileExplorer } from "./file-explorer"; +import { EditorArea } from "./editor-area"; +import { RightSidebar } from "./right-sidebar"; +import { StatusBar } from "./status-bar"; + +export function StudioLayout() { + const { loading } = useClient(); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Main content */} +
+ + {/* Left: File explorer */} + +
+ +
+
+ + + {/* Center: Editor + Output */} + + + + + + {/* Right: Compile + Deploy */} + +
+ +
+
+
+
+ + {/* Status bar — reserved height, not absolute */} + +
+ ); +} From 9c1714c09850a0d897b1bbb56fe1ece45042c7cb Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 07:20:11 +0530 Subject: [PATCH 13/20] Add multi-file Solidity compilation support - Add compileSoliditySources() client function with 450KB size preflight - Extend /api/compile to accept sources (multi-file) alongside source (single-file, backward compatible) - Validate flat namespace: reject source keys containing / or .. - Add flat-namespace error hint for relative imports (./Token.sol) when basename matches a user source file - Raise body size limit from 100KB to 500KB for multi-file workspaces - Add resolveAllImportsSources() with user-source authority and CDN shadowing prevention - Sort compiled contracts with user files first, dependencies last --- app/api/compile/route.ts | 101 +++++++++++++++++++++++++++++++++------ lib/compile-client.ts | 69 ++++++++++++++++++++++++++ lib/import-resolver.ts | 76 +++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 15 deletions(-) diff --git a/app/api/compile/route.ts b/app/api/compile/route.ts index aa23fd1..20936d5 100644 --- a/app/api/compile/route.ts +++ b/app/api/compile/route.ts @@ -1,17 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { Worker } from "worker_threads"; import { COMPILE_WORKER_CODE } from "@/lib/compile-worker-code"; -import { resolveAllImports } from "@/lib/import-resolver"; +import { resolveAllImports, resolveAllImportsSources } from "@/lib/import-resolver"; import { checkRateLimit, getRateLimitReset } from "@/lib/rate-limiter"; export const runtime = "nodejs"; export const maxDuration = 60; -const MAX_BODY_SIZE = 102400; // 100KB +const MAX_BODY_SIZE = 512000; // 500KB const WORKER_TIMEOUT_MS = 30_000; interface CompileRequest { - source: string; + source?: string; + sources?: Record; mode: "evm" | "pvm"; } @@ -63,25 +64,46 @@ function compileInWorker( /** * Parse standard Solidity JSON output into our response format. * Works for both solc and resolc (same output structure). + * + * @param userSourceKeys — keys of the user's source files (for sorting: user files first) */ function parseCompilerOutput( - output: any + output: any, + userSourceKeys?: Set ): Omit { const errors: CompileResponse["errors"] = []; const warnings: CompileResponse["warnings"] = []; if (output.errors) { for (const err of output.errors) { + const msg = err.formattedMessage || err.message || ""; + if (err.severity === "warning") { warnings.push({ message: err.message, formattedMessage: err.formattedMessage, }); } else { + // Flat-namespace hint: when a "Source not found" error has a relative import path, + // check if the basename matches any user source key + let hint = ""; + if (userSourceKeys) { + const sourceMatch = msg.match(/Source "(.+?)" not found/); + if (sourceMatch) { + const importPath = sourceMatch[1]; + if (importPath.startsWith("./") || importPath.startsWith("../")) { + const basename = importPath.split("/").pop() || ""; + if (userSourceKeys.has(basename)) { + hint = ` Studio uses flat file names. Change your import to \`import "${basename}"\`.`; + } + } + } + } + errors.push({ - message: err.message, + message: err.message + hint, severity: err.severity || "error", - formattedMessage: err.formattedMessage, + formattedMessage: err.formattedMessage ? err.formattedMessage + hint : undefined, }); } } @@ -110,10 +132,17 @@ function parseCompilerOutput( } } - // Sort: user's contracts (Contract.sol) first, imported dependencies last + // Sort: user's files first, then dependencies + const isUserFile = userSourceKeys + ? (name: string) => { + const fileName = name.split(":")[0]; + return userSourceKeys.has(fileName); + } + : (name: string) => name.startsWith("Contract.sol:"); + contractNames.sort((a, b) => { - const aIsUser = a.startsWith("Contract.sol:"); - const bIsUser = b.startsWith("Contract.sol:"); + const aIsUser = isUserFile(a); + const bIsUser = isUserFile(b); if (aIsUser && !bIsUser) return -1; if (!aIsUser && bIsUser) return 1; return 0; @@ -167,23 +196,65 @@ export async function POST(request: NextRequest) { ); } - if (!req.source || typeof req.source !== "string") { + if (req.mode !== "evm" && req.mode !== "pvm") { return NextResponse.json( - { success: false, errors: [{ message: "Missing 'source' field", severity: "error" }] }, + { success: false, errors: [{ message: "Invalid 'mode': must be 'evm' or 'pvm'", severity: "error" }] }, { status: 400 } ); } - if (req.mode !== "evm" && req.mode !== "pvm") { + // Exactly one of source or sources must be provided + const hasSource = req.source && typeof req.source === "string"; + const hasSources = req.sources && typeof req.sources === "object" && !Array.isArray(req.sources); + if (!hasSource && !hasSources) { return NextResponse.json( - { success: false, errors: [{ message: "Invalid 'mode': must be 'evm' or 'pvm'", severity: "error" }] }, + { success: false, errors: [{ message: "Missing 'source' or 'sources' field", severity: "error" }] }, + { status: 400 } + ); + } + if (hasSource && hasSources) { + return NextResponse.json( + { success: false, errors: [{ message: "Provide 'source' or 'sources', not both", severity: "error" }] }, { status: 400 } ); } + // Validate multi-file sources (flat namespace) + let userSourceKeys: Set | undefined; + if (hasSources) { + userSourceKeys = new Set(); + for (const [key, val] of Object.entries(req.sources!)) { + if (key.includes("/") || key.includes("..")) { + return NextResponse.json( + { success: false, errors: [{ message: `Invalid source key "${key}": must be a flat filename (no paths)`, severity: "error" }] }, + { status: 400 } + ); + } + if (!key || typeof (val as any)?.content !== "string") { + return NextResponse.json( + { success: false, errors: [{ message: `Invalid source entry "${key}": must have string content`, severity: "error" }] }, + { status: 400 } + ); + } + userSourceKeys.add(key); + } + } + try { // Step 1: Resolve imports from CDN - const { sources, resolvedVersions } = await resolveAllImports(req.source); + let sources: Record; + let resolvedVersions: Record; + + if (hasSources) { + const resolved = await resolveAllImportsSources(req.sources!); + sources = resolved.sources; + resolvedVersions = resolved.resolvedVersions; + } else { + const resolved = await resolveAllImports(req.source!); + sources = resolved.sources; + resolvedVersions = resolved.resolvedVersions; + userSourceKeys = new Set(["Contract.sol"]); + } // Step 2: Build compiler input let input: string; @@ -211,7 +282,7 @@ export async function POST(request: NextRequest) { const output = await compileInWorker(input, req.mode); // Step 4: Parse output - const result = parseCompilerOutput(output); + const result = parseCompilerOutput(output, userSourceKeys); return NextResponse.json({ ...result, resolvedVersions } as CompileResponse); } catch (error) { diff --git a/lib/compile-client.ts b/lib/compile-client.ts index fcc648a..6510cb9 100644 --- a/lib/compile-client.ts +++ b/lib/compile-client.ts @@ -57,3 +57,72 @@ export async function compileSolidity( warnings: data.warnings || [], }; } + +const MAX_CLIENT_BODY_SIZE = 450_000; // 450KB — headroom for JSON overhead + +/** + * Compile multiple Solidity source files via the /api/compile endpoint. + * Used by the Studio's multi-file workspace. + */ +export async function compileSoliditySources( + sources: Record, + mode: "evm" | "pvm" +): Promise { + // Client-side size preflight + const body = JSON.stringify({ sources, mode }); + if (body.length > MAX_CLIENT_BODY_SIZE) { + return { + success: false, + contracts: null, + contractNames: [], + errors: [ + { + message: `Source files too large (${(body.length / 1024).toFixed(0)}KB, max ~440KB). Reduce file sizes or compile externally.`, + severity: "error", + }, + ], + warnings: [], + }; + } + + const res = await fetch("/api/compile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + + const data = await res.json(); + + if (!res.ok) { + return { + success: false, + contracts: null, + contractNames: [], + errors: data.errors || [ + { + message: data.error || "Compilation failed", + severity: "error", + }, + ], + warnings: [], + }; + } + + if (!data.success) { + return { + success: false, + contracts: null, + contractNames: [], + errors: data.errors || [{ message: "Compilation failed", severity: "error" }], + warnings: data.warnings || [], + }; + } + + return { + success: true, + contracts: data.contracts || {}, + contractNames: data.contractNames || [], + errors: data.errors || [], + warnings: data.warnings || [], + }; +} diff --git a/lib/import-resolver.ts b/lib/import-resolver.ts index aed6a29..5c75636 100644 --- a/lib/import-resolver.ts +++ b/lib/import-resolver.ts @@ -216,6 +216,82 @@ function extractMissingImports( return [...new Set(missing)]; // Deduplicate } +/** + * Resolve all imports for multiple user source files. + * User sources are authoritative — they are never overwritten by CDN fetches. + * The compiler handles relative imports between user files natively. + */ +export async function resolveAllImportsSources( + userSources: Record +): Promise { + const abortController = new AbortController(); + const session = new ResolutionSession(abortController); + const budgetTimer = setTimeout(() => abortController.abort(), TOTAL_BUDGET_MS); + const userKeys = new Set(Object.keys(userSources)); + + try { + const sources: Record = { ...userSources }; + + // Quick check: if no source has imports, skip resolution + const hasAnyImport = Object.values(userSources).some((s) => + s.content.includes("import") + ); + if (!hasAnyImport) { + return { sources, resolvedVersions: {} }; + } + + // eslint-disable-next-line + const solc = require("solc"); + + for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { + const input = JSON.stringify({ + language: "Solidity", + sources, + settings: { outputSelection: {} }, + }); + + const output = JSON.parse(solc.compile(input)); + const errors = output.errors || []; + const missingPaths = extractMissingImports(errors); + + if (missingPaths.length === 0) break; + + for (const importPath of missingPaths) { + if (sources[importPath]) continue; + + // CDN shadowing prevention: don't fetch if name matches a user source + const basename = importPath.split("/").pop() || ""; + if (userKeys.has(importPath) || userKeys.has(basename)) continue; + + let resolvedPath: string | null = null; + for (const existingPath of Object.keys(sources)) { + const candidate = session.resolveRelativePath(existingPath, importPath); + if (candidate && !sources[candidate]) { + resolvedPath = candidate; + break; + } + } + + const pathToFetch = resolvedPath || importPath; + const content = await session.fetchFile(pathToFetch); + sources[pathToFetch] = { content }; + + if (resolvedPath && resolvedPath !== importPath) { + sources[importPath] = { content }; + } + } + } + + return { + sources, + resolvedVersions: session.getResolvedVersions(), + }; + } finally { + clearTimeout(budgetTimer); + abortController.abort(); + } +} + /** * Resolve all imports for a Solidity source file using compiler-driven iteration. * From 64e93233fc571fda334cad2a781fd86738c05886 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 07:20:23 +0530 Subject: [PATCH 14/20] Add tests for Studio reducer and compile/upload state transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add studio-reducer.test.ts (23 tests): file ops (create, rename with duplicate rejection, delete with last-file guard), dirty tracking (content hash changes on edit/rename/delete/create), tab management (open, close with neighbor focus, switch), content hash determinism - Add studio-integration.test.ts (12 tests): compile race protection (stale response discard, upload-during-compile cancellation), compile failure artifact preservation, mixed artifact transitions (EVM→ABI, PVM→upload, upload→ABI layering), deploy gate rules --- __tests__/studio-integration.test.ts | 301 +++++++++++++++++++++++++++ __tests__/studio-reducer.test.ts | 300 ++++++++++++++++++++++++++ 2 files changed, 601 insertions(+) create mode 100644 __tests__/studio-integration.test.ts create mode 100644 __tests__/studio-reducer.test.ts diff --git a/__tests__/studio-integration.test.ts b/__tests__/studio-integration.test.ts new file mode 100644 index 0000000..1954d8d --- /dev/null +++ b/__tests__/studio-integration.test.ts @@ -0,0 +1,301 @@ +/** + * Integration tests for the riskiest async state transitions in Studio and Builder. + * + * Tests compile race protection, upload-during-compile cancellation, + * compile failure artifact preservation, and mixed artifact flows. + * + * These are pure-logic tests that exercise the handler patterns without rendering + * React components. They simulate the state mutations that handleCompile/handleUpload + * perform against ContractCompilationState. + */ +import type { ContractCompilationState } from "@/lib/contract-store"; + +// --- Helpers that mirror the handler logic --- + +function createInitialState(): ContractCompilationState { + return { + abi: null, + contractName: null, + bytecode: null, + contractNames: [], + allContracts: null, + errors: [], + warnings: [], + isCompiling: false, + mode: null, + bytecodeSource: null, + }; +} + +function applyUpdate( + state: ContractCompilationState, + update: Partial +): ContractCompilationState { + return { ...state, ...update }; +} + +/** Simulate a successful compile result applied to state */ +function applyCompileSuccess( + state: ContractCompilationState, + target: "evm" | "pvm" +): ContractCompilationState { + return applyUpdate(state, { + allContracts: { "Contract.sol:MyToken": { abi: [{ type: "constructor" }], bytecode: "aabb" } }, + contractName: "Contract.sol:MyToken", + abi: [{ type: "constructor" }], + bytecode: "aabb", + contractNames: ["Contract.sol:MyToken"], + errors: [], + warnings: [], + isCompiling: false, + mode: target, + bytecodeSource: "compile", + }); +} + +/** Simulate a compile failure applied to state (preserves previous artifacts) */ +function applyCompileFailure( + state: ContractCompilationState +): ContractCompilationState { + return applyUpdate(state, { + isCompiling: false, + errors: [{ message: "ParserError: Expected ';'", severity: "error" }], + warnings: [], + }); +} + +/** Simulate bytecode upload applied to state (full artifact reset) */ +function applyBytecodeUpload( + state: ContractCompilationState, + bytecodeHex: string +): ContractCompilationState { + return applyUpdate(state, { + bytecode: bytecodeHex, + bytecodeSource: "upload", + mode: null, + abi: null, + allContracts: null, + contractNames: [], + contractName: null, + errors: [], + warnings: [], + isCompiling: false, + }); +} + +/** Simulate ABI upload applied to state (layers on top, does NOT touch bytecode) */ +function applyAbiUpload( + state: ContractCompilationState, + abi: any[], + name: string +): ContractCompilationState { + return applyUpdate(state, { + abi, + contractName: name, + }); +} + +// --- Compile Race Protection --- + +describe("Compile race protection", () => { + it("stale compile response is discarded when compileIdRef mismatches", () => { + // Simulate: start compile A, start compile B, A finishes + let compileId = 0; + let state = createInitialState(); + + // Compile A starts + const idA = ++compileId; + state = applyUpdate(state, { isCompiling: true, errors: [], warnings: [] }); + + // Compile B starts before A finishes + const idB = ++compileId; + state = applyUpdate(state, { isCompiling: true, errors: [], warnings: [] }); + + // Compile A finishes — but compileId !== idA, so it should be discarded + if (compileId === idA) { + state = applyCompileSuccess(state, "pvm"); + } + // State should still be compiling (A was discarded) + expect(state.isCompiling).toBe(true); + expect(state.bytecode).toBeNull(); + + // Compile B finishes — compileId === idB, so it should be applied + if (compileId === idB) { + state = applyCompileSuccess(state, "pvm"); + } + expect(state.isCompiling).toBe(false); + expect(state.bytecode).toBe("aabb"); + expect(state.bytecodeSource).toBe("compile"); + expect(state.mode).toBe("pvm"); + }); + + it("upload during in-flight compile invalidates the compile token", () => { + let compileId = 0; + let state = createInitialState(); + let isCompiling = false; + + // Start compile + const idA = ++compileId; + isCompiling = true; + state = applyUpdate(state, { isCompiling: true, errors: [], warnings: [] }); + + // Upload bytecode while compile is in-flight — bumps token + clears local spinner + ++compileId; + isCompiling = false; + state = applyBytecodeUpload(state, "deadbeef"); + + // Verify upload took effect + expect(state.bytecode).toBe("deadbeef"); + expect(state.bytecodeSource).toBe("upload"); + expect(state.mode).toBeNull(); + expect(state.isCompiling).toBe(false); + expect(isCompiling).toBe(false); + + // Late compile A finishes — compileId !== idA, discarded + if (compileId === idA) { + state = applyCompileSuccess(state, "pvm"); + isCompiling = false; + } + // Upload state should be preserved, not overwritten + expect(state.bytecode).toBe("deadbeef"); + expect(state.bytecodeSource).toBe("upload"); + expect(state.mode).toBeNull(); + }); +}); + +// --- Compile Failure Policy --- + +describe("Compile failure preserves previous artifacts", () => { + it("keeps previous successful artifacts on compile failure", () => { + let state = createInitialState(); + + // First compile succeeds + state = applyCompileSuccess(state, "pvm"); + expect(state.bytecode).toBe("aabb"); + expect(state.mode).toBe("pvm"); + expect(state.bytecodeSource).toBe("compile"); + + // Second compile fails — should preserve first compile's artifacts + state = applyCompileFailure(state); + expect(state.bytecode).toBe("aabb"); // Preserved + expect(state.abi).toEqual([{ type: "constructor" }]); // Preserved + expect(state.mode).toBe("pvm"); // Preserved + expect(state.bytecodeSource).toBe("compile"); // Preserved + expect(state.errors).toHaveLength(1); // Error recorded + expect(state.isCompiling).toBe(false); + }); + + it("deploy stays blocked after compile failure (dirty state)", () => { + let state = createInitialState(); + + // Compile succeeds + state = applyCompileSuccess(state, "pvm"); + + // Simulate user edits (content hash would change) + // Then compile fails + state = applyCompileFailure(state); + + // Previous artifacts exist but workspace is dirty (compiledContentHash not updated) + // The deploy section checks isDirtySinceCompile which would be true + expect(state.bytecode).toBe("aabb"); + expect(state.errors).toHaveLength(1); + // compiledContentHash is NOT updated on failure — isDirtySinceCompile stays true + }); +}); + +// --- Mixed Artifact Transitions --- + +describe("Mixed artifact transitions", () => { + it("compile EVM → upload ABI only → deploy still blocked by EVM gate", () => { + let state = createInitialState(); + + // Compile EVM + state = applyCompileSuccess(state, "evm"); + expect(state.mode).toBe("evm"); + expect(state.bytecodeSource).toBe("compile"); + + // Upload ABI only — does NOT change bytecodeSource or mode + state = applyAbiUpload(state, [{ type: "function", name: "foo" }], "MyAbi"); + expect(state.bytecodeSource).toBe("compile"); // Unchanged + expect(state.mode).toBe("evm"); // Unchanged — still EVM-blocked + expect(state.contractName).toBe("MyAbi"); // ABI name updated + }); + + it("compile PVM → upload new .bin → full artifact reset", () => { + let state = createInitialState(); + + // Compile PVM + state = applyCompileSuccess(state, "pvm"); + expect(state.bytecodeSource).toBe("compile"); + expect(state.mode).toBe("pvm"); + expect(state.abi).toEqual([{ type: "constructor" }]); + + // Upload new bytecode — full reset + state = applyBytecodeUpload(state, "cafebabe"); + expect(state.bytecodeSource).toBe("upload"); + expect(state.mode).toBeNull(); + expect(state.abi).toBeNull(); // Cleared + expect(state.allContracts).toBeNull(); // Cleared + expect(state.contractName).toBeNull(); // Cleared + expect(state.bytecode).toBe("cafebabe"); + }); + + it("upload .bin → upload .json → ABI layered, deploy enabled", () => { + let state = createInitialState(); + + // Upload bytecode + state = applyBytecodeUpload(state, "deadbeef"); + expect(state.abi).toBeNull(); + + // Upload ABI + state = applyAbiUpload(state, [{ type: "constructor", inputs: [] }], "Uploaded"); + expect(state.bytecode).toBe("deadbeef"); // Unchanged + expect(state.bytecodeSource).toBe("upload"); // Unchanged + expect(state.abi).toEqual([{ type: "constructor", inputs: [] }]); // Layered + expect(state.contractName).toBe("Uploaded"); + }); + + it(".bin upload clears previous ABI (bytecode/ABI must match)", () => { + let state = createInitialState(); + + // Compile to get ABI + bytecode + state = applyCompileSuccess(state, "pvm"); + expect(state.abi).toEqual([{ type: "constructor" }]); + + // Upload new bytecode — ABI must be cleared + state = applyBytecodeUpload(state, "newcode"); + expect(state.abi).toBeNull(); + expect(state.bytecode).toBe("newcode"); + }); +}); + +// --- Deploy Gate Logic --- + +describe("Deploy gate rules", () => { + it("compile source: enabled when PVM + not dirty", () => { + const state = applyCompileSuccess(createInitialState(), "pvm"); + const enabled = + state.bytecodeSource === "compile" && + state.mode === "pvm" && + !!state.bytecode; + expect(enabled).toBe(true); + }); + + it("compile source: blocked when EVM", () => { + const state = applyCompileSuccess(createInitialState(), "evm"); + const blocked = state.mode === "evm"; + expect(blocked).toBe(true); + }); + + it("upload source: enabled when bytecode exists (no dirty check)", () => { + const state = applyBytecodeUpload(createInitialState(), "aabb"); + const enabled = state.bytecodeSource === "upload" && !!state.bytecode; + expect(enabled).toBe(true); + }); + + it("no source: disabled when bytecodeSource is null", () => { + const state = createInitialState(); + const enabled = state.bytecodeSource !== null && !!state.bytecode; + expect(enabled).toBe(false); + }); +}); diff --git a/__tests__/studio-reducer.test.ts b/__tests__/studio-reducer.test.ts new file mode 100644 index 0000000..c0d0124 --- /dev/null +++ b/__tests__/studio-reducer.test.ts @@ -0,0 +1,300 @@ +/** + * Pure reducer + state tests for StudioProvider. + * + * Tests file ops, dirty tracking, and tab management without React rendering. + * Imports the real reducer to prevent drift from production behavior. + */ +import type { StudioState } from "@/types/studio"; +import { contentHash, createDefaultState, getAllSources } from "@/types/studio"; +import { studioReducer } from "@/context/studio-provider"; + +// Helper to get the first file ID from state +function firstFileId(state: StudioState): string { + return Object.keys(state.files)[0]; +} + +function isDirtySinceCompile(state: StudioState): boolean { + const sources = getAllSources(state.files); + const hash = contentHash(sources); + return state.compiledContentHash === null || state.compiledContentHash !== hash; +} + +describe("StudioReducer", () => { + let state: StudioState; + + beforeEach(() => { + state = createDefaultState(); + }); + + // --- File Operations --- + + describe("CREATE_FILE", () => { + it("creates a new file and opens a tab", () => { + const next = studioReducer(state, { + type: "CREATE_FILE", + name: "Token.sol", + content: "// token", + }); + const files = Object.values(next.files); + expect(files).toHaveLength(2); + const newFile = files.find((f) => f.name === "Token.sol"); + expect(newFile).toBeDefined(); + expect(newFile!.content).toBe("// token"); + expect(next.openTabs).toHaveLength(2); + expect(next.activeTabId).toBe(newFile!.id); + }); + + it("rejects duplicate names", () => { + const next = studioReducer(state, { + type: "CREATE_FILE", + name: "Contract.sol", + }); + expect(next).toBe(state); // No change + }); + }); + + describe("RENAME_FILE", () => { + it("renames a file", () => { + const id = firstFileId(state); + const next = studioReducer(state, { + type: "RENAME_FILE", + fileId: id, + newName: "MyContract.sol", + }); + expect(next.files[id].name).toBe("MyContract.sol"); + }); + + it("rejects duplicate target name", () => { + // Create a second file first + const s1 = studioReducer(state, { + type: "CREATE_FILE", + name: "Token.sol", + }); + const id = firstFileId(s1); + const next = studioReducer(s1, { + type: "RENAME_FILE", + fileId: id, + newName: "Token.sol", + }); + expect(next).toBe(s1); // No change + }); + }); + + describe("DELETE_FILE", () => { + it("deletes a file and closes its tab", () => { + // Create second file + const s1 = studioReducer(state, { + type: "CREATE_FILE", + name: "Token.sol", + }); + const tokenId = Object.values(s1.files).find( + (f) => f.name === "Token.sol" + )!.id; + const next = studioReducer(s1, { + type: "DELETE_FILE", + fileId: tokenId, + }); + expect(Object.keys(next.files)).toHaveLength(1); + expect(next.openTabs.find((t) => t.fileId === tokenId)).toBeUndefined(); + }); + + it("blocks deletion of last file", () => { + const id = firstFileId(state); + const next = studioReducer(state, { + type: "DELETE_FILE", + fileId: id, + }); + expect(next).toBe(state); // No change + }); + + it("focuses neighbor tab after deleting active tab", () => { + // Create 3 files: A, B, C. Delete B (active). + let s = studioReducer(state, { + type: "CREATE_FILE", + name: "B.sol", + }); + s = studioReducer(s, { type: "CREATE_FILE", name: "C.sol" }); + const bId = Object.values(s.files).find((f) => f.name === "B.sol")!.id; + + // Make B active + s = studioReducer(s, { type: "SET_ACTIVE_TAB", fileId: bId }); + expect(s.activeTabId).toBe(bId); + + const next = studioReducer(s, { type: "DELETE_FILE", fileId: bId }); + expect(next.activeTabId).not.toBe(bId); + expect(next.activeTabId).not.toBeNull(); + }); + }); + + describe("UPDATE_FILE_CONTENT", () => { + it("updates file content", () => { + const id = firstFileId(state); + const next = studioReducer(state, { + type: "UPDATE_FILE_CONTENT", + fileId: id, + content: "new content", + }); + expect(next.files[id].content).toBe("new content"); + }); + }); + + // --- Dirty Tracking --- + + describe("isDirtySinceCompile", () => { + it("is dirty initially (compiledContentHash is null)", () => { + expect(isDirtySinceCompile(state)).toBe(true); + }); + + it("becomes clean after setting compiled hash", () => { + const sources = getAllSources(state.files); + const hash = contentHash(sources); + const next = studioReducer(state, { + type: "SET_COMPILED_HASH", + hash, + }); + expect(isDirtySinceCompile(next)).toBe(false); + }); + + it("becomes dirty after editing", () => { + const sources = getAllSources(state.files); + const hash = contentHash(sources); + let s = studioReducer(state, { type: "SET_COMPILED_HASH", hash }); + expect(isDirtySinceCompile(s)).toBe(false); + + const id = firstFileId(s); + s = studioReducer(s, { + type: "UPDATE_FILE_CONTENT", + fileId: id, + content: "changed", + }); + expect(isDirtySinceCompile(s)).toBe(true); + }); + + it("becomes dirty after rename (name is part of hash)", () => { + const sources = getAllSources(state.files); + const hash = contentHash(sources); + let s = studioReducer(state, { type: "SET_COMPILED_HASH", hash }); + + const id = firstFileId(s); + s = studioReducer(s, { + type: "RENAME_FILE", + fileId: id, + newName: "Renamed.sol", + }); + expect(isDirtySinceCompile(s)).toBe(true); + }); + + it("becomes dirty after creating a file", () => { + const sources = getAllSources(state.files); + const hash = contentHash(sources); + let s = studioReducer(state, { type: "SET_COMPILED_HASH", hash }); + + s = studioReducer(s, { type: "CREATE_FILE", name: "New.sol" }); + expect(isDirtySinceCompile(s)).toBe(true); + }); + + it("becomes dirty after deleting a file", () => { + // Need 2 files to delete + let s = studioReducer(state, { + type: "CREATE_FILE", + name: "Extra.sol", + }); + const sources = getAllSources(s.files); + const hash = contentHash(sources); + s = studioReducer(s, { type: "SET_COMPILED_HASH", hash }); + + const extraId = Object.values(s.files).find( + (f) => f.name === "Extra.sol" + )!.id; + s = studioReducer(s, { type: "DELETE_FILE", fileId: extraId }); + expect(isDirtySinceCompile(s)).toBe(true); + }); + }); + + // --- Tab Management --- + + describe("OPEN_TAB", () => { + it("opens a new tab and makes it active", () => { + const s = studioReducer(state, { + type: "CREATE_FILE", + name: "B.sol", + }); + const bId = Object.values(s.files).find((f) => f.name === "B.sol")!.id; + // Close B's tab, then re-open it + const s2 = studioReducer(s, { type: "CLOSE_TAB", fileId: bId }); + const s3 = studioReducer(s2, { type: "OPEN_TAB", fileId: bId }); + expect(s3.openTabs.find((t) => t.fileId === bId)).toBeDefined(); + expect(s3.activeTabId).toBe(bId); + }); + + it("does not duplicate existing tabs", () => { + const id = firstFileId(state); + const next = studioReducer(state, { type: "OPEN_TAB", fileId: id }); + expect(next.openTabs).toHaveLength(1); + expect(next.activeTabId).toBe(id); + }); + }); + + describe("CLOSE_TAB", () => { + it("closes a tab and focuses neighbor", () => { + let s = studioReducer(state, { + type: "CREATE_FILE", + name: "B.sol", + }); + const aId = firstFileId(state); + // Active is B, close B → should focus A + const next = studioReducer(s, { + type: "CLOSE_TAB", + fileId: s.activeTabId!, + }); + expect(next.openTabs.find((t) => t.fileId === s.activeTabId)).toBeUndefined(); + expect(next.activeTabId).toBe(aId); + }); + + it("sets activeTabId to null when all tabs closed", () => { + const id = firstFileId(state); + const next = studioReducer(state, { type: "CLOSE_TAB", fileId: id }); + expect(next.openTabs).toHaveLength(0); + expect(next.activeTabId).toBeNull(); + }); + }); + + describe("SET_ACTIVE_TAB", () => { + it("switches active tab", () => { + const s = studioReducer(state, { + type: "CREATE_FILE", + name: "B.sol", + }); + const aId = firstFileId(state); + const next = studioReducer(s, { type: "SET_ACTIVE_TAB", fileId: aId }); + expect(next.activeTabId).toBe(aId); + }); + }); + + // --- Content Hash --- + + describe("contentHash", () => { + it("produces deterministic hashes", () => { + const sources = { "A.sol": { content: "abc" }, "B.sol": { content: "def" } }; + expect(contentHash(sources)).toBe(contentHash(sources)); + }); + + it("is order-independent (sorted keys)", () => { + const a = { "A.sol": { content: "abc" }, "B.sol": { content: "def" } }; + const b = { "B.sol": { content: "def" }, "A.sol": { content: "abc" } }; + expect(contentHash(a)).toBe(contentHash(b)); + }); + + it("changes when content changes", () => { + const a = { "A.sol": { content: "abc" } }; + const b = { "A.sol": { content: "xyz" } }; + expect(contentHash(a)).not.toBe(contentHash(b)); + }); + + it("changes when filename changes", () => { + const a = { "A.sol": { content: "abc" } }; + const b = { "B.sol": { content: "abc" } }; + expect(contentHash(a)).not.toBe(contentHash(b)); + }); + }); +}); From 6c57013f7c8f677c8df1e2bca0e8cfa2e4d70655 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 07:20:29 +0530 Subject: [PATCH 15/20] Remove old Studio components replaced by IDE layout Remove contract-studio.tsx, compile-panel.tsx, and editor-panel.tsx. These are fully replaced by the new studio-layout.tsx, file-explorer.tsx, editor-area.tsx, compiler-section.tsx, and deploy-section.tsx components. --- components/studio/compile-panel.tsx | 287 -------------------------- components/studio/contract-studio.tsx | 68 ------ components/studio/editor-panel.tsx | 116 ----------- 3 files changed, 471 deletions(-) delete mode 100644 components/studio/compile-panel.tsx delete mode 100644 components/studio/contract-studio.tsx delete mode 100644 components/studio/editor-panel.tsx diff --git a/components/studio/compile-panel.tsx b/components/studio/compile-panel.tsx deleted file mode 100644 index d134737..0000000 --- a/components/studio/compile-panel.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client"; - -import React, { useRef, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { ModeToggle } from "@/components/params/shared/mode-toggle"; -import { useContractContext } from "@/context/contract-provider"; -import { compileSolidity } from "@/lib/compile-client"; -import { Upload, Play, Loader2, Check, AlertCircle, FileCode } from "lucide-react"; - -interface CompilePanelProps { - source: string; - onSourceChange: (source: string) => void; -} - -export function CompilePanel({ source, onSourceChange }: CompilePanelProps) { - const { setCompilation, resetCompilation, selectContract, ...compilation } = - useContractContext(); - const [compileTarget, setCompileTarget] = useState<"evm" | "pvm">("pvm"); - const [isCompiling, setIsCompiling] = useState(false); - const [fileName, setFileName] = useState(null); - const [abiFileName, setAbiFileName] = useState(null); - const bytecodeInputRef = useRef(null); - const abiInputRef = useRef(null); - - const handleCompile = async () => { - if (!source.trim()) return; - setIsCompiling(true); - setCompilation({ isCompiling: true, errors: [], warnings: [] }); - - try { - const result = await compileSolidity(source, compileTarget); - - if (!result.success) { - resetCompilation(); - setCompilation({ - isCompiling: false, - errors: result.errors, - warnings: result.warnings, - }); - return; - } - - const contractNames = result.contractNames; - const firstContract = contractNames[0] || null; - const firstData = firstContract ? result.contracts?.[firstContract] : null; - - setCompilation({ - allContracts: result.contracts, - contractName: firstContract, - abi: firstData?.abi || null, - bytecode: firstData?.bytecode || null, - contractNames, - errors: result.errors, - warnings: result.warnings, - isCompiling: false, - mode: compileTarget, - }); - } catch (err) { - resetCompilation(); - setCompilation({ - isCompiling: false, - errors: [ - { - message: err instanceof Error ? err.message : "Network error", - severity: "error", - }, - ], - }); - } finally { - setIsCompiling(false); - } - }; - - const handleContractSelect = (name: string) => { - selectContract(name); - }; - - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setFileName(file.name); - const reader = new FileReader(); - reader.onload = () => { - const content = reader.result as string; - - // .sol files → load into editor - if (file.name.endsWith(".sol")) { - onSourceChange(content); - return; - } - - // .bin / .hex → parse as hex bytecode - const raw = content.trim().replace(/\s+/g, ""); - const normalized = raw.startsWith("0x") || raw.startsWith("0X") - ? `0x${raw.slice(2).toLowerCase()}` - : `0x${raw.toLowerCase()}`; - if (!/^0x[0-9a-f]*$/.test(normalized) || normalized.length % 2 !== 0) { - setCompilation({ - bytecode: null, - allContracts: null, - contractNames: [], - errors: [{ message: "Invalid bytecode file: must be valid hex with even length", severity: "error" }], - }); - return; - } - setCompilation({ - bytecode: normalized.slice(2), // store without 0x prefix - errors: [], - }); - }; - reader.readAsText(file); - }; - - const handleAbiUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setAbiFileName(file.name); - const reader = new FileReader(); - reader.onload = () => { - try { - const abi = JSON.parse(reader.result as string); - setCompilation({ - abi: Array.isArray(abi) ? abi : abi.abi || [], - contractName: file.name.replace(/\.json$/, ""), - }); - } catch { - setCompilation({ - abi: null, - contractName: null, - bytecode: null, - allContracts: null, - contractNames: [], - errors: [{ message: "Failed to parse ABI JSON", severity: "error" }], - }); - } - }; - reader.readAsText(file); - }; - - const byteCount = compilation.bytecode - ? Math.floor(compilation.bytecode.length / 2) - : 0; - const abiCount = compilation.abi?.length ?? 0; - - return ( -
- {/* Compile section */} -
-

- Compile -

- setCompileTarget(m as "evm" | "pvm")} - disabled={isCompiling} - /> - -
- - {/* Contract selector */} - {compilation.contractNames.length > 1 && ( -
- Contract - -
- )} - - {/* Info */} - {compilation.bytecode && ( -
-
- - - {compilation.contractName || "Contract"} - -
-
-

{byteCount.toLocaleString()} bytes

- {abiCount > 0 && ( -

ABI: {abiCount} entries

- )} -
- {!isCompiling && compilation.errors.length === 0 && ( - - - Compiled - - )} -
- )} - - {/* Upload section */} -
-

- Upload -

- - - - -
- - {/* Errors */} - {compilation.errors.length > 0 && ( -
- {compilation.errors.map((err, i) => ( -
- - {err.formattedMessage || err.message} -
- ))} -
- )} -
- ); -} diff --git a/components/studio/contract-studio.tsx b/components/studio/contract-studio.tsx deleted file mode 100644 index 4489f32..0000000 --- a/components/studio/contract-studio.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { - ResizablePanel, - ResizablePanelGroup, - ResizableHandle, -} from "@/components/ui/resizable"; -import { useClient } from "@/context/client"; -import { Skeleton } from "@/components/ui/skeleton"; -import { CompilePanel } from "./compile-panel"; -import { EditorPanel } from "./editor-panel"; -import { DeployPanel } from "./deploy-panel"; - -const DEFAULT_SOLIDITY = `// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -contract MyToken { - string public name; - uint256 public totalSupply; - mapping(address => uint256) public balanceOf; - - constructor(string memory _name, uint256 _initialSupply) { - name = _name; - totalSupply = _initialSupply; - balanceOf[msg.sender] = _initialSupply; - } -}`; - -export function ContractStudio() { - const { client, loading } = useClient(); - const [source, setSource] = useState(DEFAULT_SOLIDITY); - - if (loading) { - return ( -
- -
- ); - } - - return ( -
- - {/* Left: Compile panel */} - -
- -
-
- - - {/* Center: Editor + Output */} - - - - - - {/* Right: Deploy panel */} - -
- -
-
-
-
- ); -} diff --git a/components/studio/editor-panel.tsx b/components/studio/editor-panel.tsx deleted file mode 100644 index fdb5382..0000000 --- a/components/studio/editor-panel.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import React from "react"; -import dynamic from "next/dynamic"; -import { useTheme } from "next-themes"; -import { useContractContext } from "@/context/contract-provider"; -import { - ResizablePanel, - ResizablePanelGroup, - ResizableHandle, -} from "@/components/ui/resizable"; -import { Check, AlertCircle, AlertTriangle } from "lucide-react"; - -const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { - ssr: false, -}); - -interface EditorPanelProps { - source: string; - onSourceChange: (source: string) => void; -} - -export function EditorPanel({ source, onSourceChange }: EditorPanelProps) { - const { resolvedTheme } = useTheme(); - const compilation = useContractContext(); - - const hasOutput = - compilation.errors.length > 0 || - compilation.warnings.length > 0 || - compilation.bytecode; - - return ( - - -
- onSourceChange(val || "")} - options={{ - minimap: { enabled: true }, - fontSize: 14, - lineNumbers: "on", - scrollBeyondLastLine: false, - wordWrap: "on", - tabSize: 4, - padding: { top: 8, bottom: 8 }, - }} - /> -
-
- - -
- {!hasOutput && ( -

- Output will appear here after compilation -

- )} - - {compilation.isCompiling && ( -

Compiling...

- )} - - {compilation.bytecode && - compilation.errors.length === 0 && - !compilation.isCompiling && ( -
- - Compiled successfully - {compilation.contractName && ( - - ({compilation.contractName}) - - )} -
- )} - - {compilation.errors.length > 0 && ( -
- {compilation.errors.map((err, i) => ( -
- -
-                    {err.formattedMessage || err.message}
-                  
-
- ))} -
- )} - - {compilation.warnings.length > 0 && ( -
- {compilation.warnings.map((warn, i) => ( -
- -
-                    {warn.formattedMessage || warn.message}
-                  
-
- ))} -
- )} -
-
-
- ); -} From ca48e6be56f9cb938a619c57d093bcba0589f886 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 07:37:52 +0530 Subject: [PATCH 16/20] Fix stale gas estimates, CDN shadowing, and filename validation - Invalidate weightRequired/storageDeposit when deploy inputs (value, code, data, salt) change, preventing deploy with stale gas limits - Narrow CDN shadowing check to exact key match only; basename collision was blocking legitimate dependencies like @openzeppelin/.../Context.sol when a user file shared the same basename - Reject filenames containing /, .., or \ in CREATE_FILE and RENAME_FILE reducer actions, matching the flat namespace rules enforced by /api/compile --- __tests__/studio-reducer.test.ts | 12 ++++++++++++ context/studio-provider.tsx | 12 ++++++++++-- hooks/use-gas-estimation.ts | 21 ++++++++++++++++++++- lib/import-resolver.ts | 8 +++++--- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/__tests__/studio-reducer.test.ts b/__tests__/studio-reducer.test.ts index c0d0124..0f3052a 100644 --- a/__tests__/studio-reducer.test.ts +++ b/__tests__/studio-reducer.test.ts @@ -51,6 +51,12 @@ describe("StudioReducer", () => { }); expect(next).toBe(state); // No change }); + + it("rejects names with path separators", () => { + expect(studioReducer(state, { type: "CREATE_FILE", name: "foo/bar.sol" })).toBe(state); + expect(studioReducer(state, { type: "CREATE_FILE", name: "../Token.sol" })).toBe(state); + expect(studioReducer(state, { type: "CREATE_FILE", name: "path\\file.sol" })).toBe(state); + }); }); describe("RENAME_FILE", () => { @@ -64,6 +70,12 @@ describe("StudioReducer", () => { expect(next.files[id].name).toBe("MyContract.sol"); }); + it("rejects rename to path-like names", () => { + const id = firstFileId(state); + expect(studioReducer(state, { type: "RENAME_FILE", fileId: id, newName: "foo/bar.sol" })).toBe(state); + expect(studioReducer(state, { type: "RENAME_FILE", fileId: id, newName: "../x.sol" })).toBe(state); + }); + it("rejects duplicate target name", () => { // Create a second file first const s1 = studioReducer(state, { diff --git a/context/studio-provider.tsx b/context/studio-provider.tsx index 5c93dec..9b0309f 100644 --- a/context/studio-provider.tsx +++ b/context/studio-provider.tsx @@ -24,12 +24,19 @@ const SESSION_KEY = "studio-workspace"; const PERSIST_DEBOUNCE_MS = 1000; const SIZE_WARN_BYTES = 50_000; +// --- Filename validation (matches /api/compile flat namespace rules) --- + +function isValidFileName(name: string): boolean { + return !!name && !name.includes("/") && !name.includes("..") && !name.includes("\\"); +} + // --- Reducer (exported for testing) --- export function studioReducer(state: StudioState, action: StudioAction): StudioState { switch (action.type) { case "CREATE_FILE": { - // Reject if name already exists + // Reject if name is invalid (contains path separators) or already exists + if (!isValidFileName(action.name)) return state; const nameExists = Object.values(state.files).some( (f) => f.name === action.name ); @@ -52,7 +59,8 @@ export function studioReducer(state: StudioState, action: StudioAction): StudioS case "RENAME_FILE": { const file = state.files[action.fileId]; if (!file) return state; - // Reject if target name exists (on a different file) + // Reject if name is invalid or target name exists (on a different file) + if (!isValidFileName(action.newName)) return state; const nameExists = Object.values(state.files).some( (f) => f.id !== action.fileId && f.name === action.newName ); diff --git a/hooks/use-gas-estimation.ts b/hooks/use-gas-estimation.ts index aca4512..042cbc3 100644 --- a/hooks/use-gas-estimation.ts +++ b/hooks/use-gas-estimation.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import type { DedotClient } from "dedot"; import type { AssetHubApi, GenericChainClient } from "@/lib/chain-types"; import { hasReviveApi } from "@/lib/chain-types"; @@ -42,6 +42,25 @@ export function useGasEstimation( const [deployedAddress, setDeployedAddress] = useState(null); const [error, setError] = useState(null); + // Invalidate gas estimates when deploy inputs change + const prevInputsRef = useRef({ value, code, data, salt }); + useEffect(() => { + const prev = prevInputsRef.current; + if ( + prev.value !== value || + prev.code !== code || + prev.data !== data || + prev.salt !== salt + ) { + prevInputsRef.current = { value, code, data, salt }; + setWeightRequired(null); + setStorageDeposit(null); + setGasConsumed(null); + setDeployedAddress(null); + setError(null); + } + }, [value, code, data, salt]); + const estimate = useCallback(async () => { if (!client) { setError("No client connected"); diff --git a/lib/import-resolver.ts b/lib/import-resolver.ts index 5c75636..27e41ac 100644 --- a/lib/import-resolver.ts +++ b/lib/import-resolver.ts @@ -259,9 +259,11 @@ export async function resolveAllImportsSources( for (const importPath of missingPaths) { if (sources[importPath]) continue; - // CDN shadowing prevention: don't fetch if name matches a user source - const basename = importPath.split("/").pop() || ""; - if (userKeys.has(importPath) || userKeys.has(basename)) continue; + // CDN shadowing prevention: only skip if the exact import path + // matches a user source key. Don't match on basename alone — that + // would block legitimate dependencies like @openzeppelin/.../Context.sol + // when the user has a local Context.sol. + if (userKeys.has(importPath)) continue; let resolvedPath: string | null = null; for (const existingPath of Object.keys(sources)) { From b99014069001e8bbd902df7c22c7b1c36dde59b2 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 07:52:05 +0530 Subject: [PATCH 17/20] Clear stale bytecode on builder compile failure and invalidate gas on account change - Builder compile failure/error now clears hexValue and calls onChange(undefined) so the extrinsic form cannot submit outdated bytecode that no longer matches the editor source - Add origin (account address) to gas estimate invalidation effect so wallet switches clear stale dry-run results and deployed address --- components/params/inputs/contract-code.tsx | 10 ++++++++-- hooks/use-gas-estimation.ts | 9 +++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/components/params/inputs/contract-code.tsx b/components/params/inputs/contract-code.tsx index 2dc698a..d27c251 100644 --- a/components/params/inputs/contract-code.tsx +++ b/components/params/inputs/contract-code.tsx @@ -124,12 +124,15 @@ export function ContractCode({ if (compileIdRef.current !== id) return; if (!result.success) { - // Compile failure: keep previous artifacts, only update errors + // Compile failure: keep previous artifacts in the store for display, + // but clear the hex output so the builder form doesn't submit stale bytecode setCompilationState({ isCompiling: false, errors: result.errors, warnings: result.warnings, }); + setHexValue(""); + onChange?.(undefined); return; } @@ -158,7 +161,8 @@ export function ContractCode({ } } catch (err) { if (compileIdRef.current !== id) return; - // Network error: keep previous artifacts, only update errors + // Network error: keep previous artifacts in the store for display, + // but clear the hex output so the builder form doesn't submit stale bytecode setCompilationState({ isCompiling: false, errors: [ @@ -169,6 +173,8 @@ export function ContractCode({ }, ], }); + setHexValue(""); + onChange?.(undefined); } finally { if (compileIdRef.current === id) { setIsCompiling(false); diff --git a/hooks/use-gas-estimation.ts b/hooks/use-gas-estimation.ts index 042cbc3..cea022d 100644 --- a/hooks/use-gas-estimation.ts +++ b/hooks/use-gas-estimation.ts @@ -42,24 +42,25 @@ export function useGasEstimation( const [deployedAddress, setDeployedAddress] = useState(null); const [error, setError] = useState(null); - // Invalidate gas estimates when deploy inputs change - const prevInputsRef = useRef({ value, code, data, salt }); + // Invalidate gas estimates when any deploy input changes (including account) + const prevInputsRef = useRef({ origin, value, code, data, salt }); useEffect(() => { const prev = prevInputsRef.current; if ( + prev.origin !== origin || prev.value !== value || prev.code !== code || prev.data !== data || prev.salt !== salt ) { - prevInputsRef.current = { value, code, data, salt }; + prevInputsRef.current = { origin, value, code, data, salt }; setWeightRequired(null); setStorageDeposit(null); setGasConsumed(null); setDeployedAddress(null); setError(null); } - }, [value, code, data, salt]); + }, [origin, value, code, data, salt]); const estimate = useCallback(async () => { if (!client) { From 44c9ed746ae6b2a2cc6adbf2d7b34663c61dbd4c Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 09:30:24 +0530 Subject: [PATCH 18/20] Fix Sign and Submit button getting stuck in submitting state - Replace LunoKit's isPending with local isSubmitting state managed in try/finally, ensuring the button always resets even if the wallet interaction hangs or the extension crashes - Add spinner icon to submitting state for clearer feedback - Improve signing toast to prompt user to approve in wallet extension - Distinguish user rejection (cancelled/rejected) from actual errors with appropriate toast messages --- .../builder/extrinsic-builder.test.tsx | 6 +-- components/builder/extrinsic-builder.tsx | 39 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/__tests__/components/builder/extrinsic-builder.test.tsx b/__tests__/components/builder/extrinsic-builder.test.tsx index 5d051f1..eeb2b23 100644 --- a/__tests__/components/builder/extrinsic-builder.test.tsx +++ b/__tests__/components/builder/extrinsic-builder.test.tsx @@ -260,16 +260,16 @@ describe("ExtrinsicBuilder", () => { expect(screen.getByText("Sign and Submit")).toBeInTheDocument(); }); - it("shows Submitting... when isPending", () => { + it("shows Sign and Submit when account connected and not submitting", () => { (useAccount as jest.Mock).mockReturnValue({ account: { address: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" }, }); (useSendTransaction as jest.Mock).mockReturnValue({ sendTransactionAsync: jest.fn(), - isPending: true, + isPending: false, }); render(); - expect(screen.getByText("Submitting...")).toBeInTheDocument(); + expect(screen.getByText("Sign and Submit")).toBeInTheDocument(); }); it("submit button disabled when no tx", () => { diff --git a/components/builder/extrinsic-builder.tsx b/components/builder/extrinsic-builder.tsx index b9f6ee6..f5946ff 100644 --- a/components/builder/extrinsic-builder.tsx +++ b/components/builder/extrinsic-builder.tsx @@ -56,7 +56,8 @@ const ExtrinsicBuilder: React.FC = ({ }) => { const sections = createSectionOptions(client.metadata.latest); const { account } = useAccount(); - const { sendTransactionAsync, isPending } = useSendTransaction(); + const { sendTransactionAsync } = useSendTransaction(); + const [isSubmitting, setIsSubmitting] = useState(false); const [methods, setMethods] = useState< { text: string; value: number }[] | null @@ -191,6 +192,7 @@ const ExtrinsicBuilder: React.FC = ({ return; } + setIsSubmitting(true); try { // Build the args array from form data matching tx field order const args = fields.map((field) => data[field.name || ""]); @@ -198,7 +200,9 @@ const ExtrinsicBuilder: React.FC = ({ // Create the submittable extrinsic by calling the tx function with args const extrinsic = (tx as any)(...args); - toast.info("Signing and submitting transaction..."); + toast.info("Signing transaction...", { + description: "Please approve in your wallet extension.", + }); const receipt = await sendTransactionAsync({ extrinsic }); @@ -207,8 +211,20 @@ const ExtrinsicBuilder: React.FC = ({ }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - toast.error("Transaction failed", { description: message }); + // Distinguish user rejection from other errors + const isUserRejection = + message.includes("Cancelled") || + message.includes("Rejected") || + message.includes("User denied") || + message.includes("rejected"); + if (isUserRejection) { + toast.info("Transaction cancelled by user."); + } else { + toast.error("Transaction failed", { description: message }); + } console.error("Error signing and sending extrinsic:", error); + } finally { + setIsSubmitting(false); } }; @@ -426,13 +442,18 @@ const ExtrinsicBuilder: React.FC = ({
From 36506a07307cd8362399b6fb02dca5c6327cecbd Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 09:33:07 +0530 Subject: [PATCH 19/20] Handle transaction dispatch errors and improve lifecycle feedback - Check receipt.status === "failed" after block inclusion to surface on-chain dispatch errors (e.g., module errors from pallet-revive) instead of showing a false success toast - Add "Signing... approve in your wallet extension" log entry in Studio deploy section for intermediate feedback - Distinguish user wallet rejection from actual transaction errors in Studio deploy log (info vs error) --- components/builder/extrinsic-builder.tsx | 15 +++++++++++--- components/studio/deploy-section.tsx | 25 ++++++++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/components/builder/extrinsic-builder.tsx b/components/builder/extrinsic-builder.tsx index f5946ff..d8a4414 100644 --- a/components/builder/extrinsic-builder.tsx +++ b/components/builder/extrinsic-builder.tsx @@ -206,9 +206,18 @@ const ExtrinsicBuilder: React.FC = ({ const receipt = await sendTransactionAsync({ extrinsic }); - toast.success("Transaction included in block", { - description: `Block: ${receipt.blockHash}`, - }); + if (receipt.status === "failed") { + const errorMsg = receipt.errorMessage || receipt.dispatchError + ? `Dispatch error: ${receipt.errorMessage || JSON.stringify(receipt.dispatchError)}` + : "Transaction failed on-chain"; + toast.error("Transaction failed", { + description: errorMsg, + }); + } else { + toast.success("Transaction included in block", { + description: `Block: ${receipt.blockHash}`, + }); + } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; // Distinguish user rejection from other errors diff --git a/components/studio/deploy-section.tsx b/components/studio/deploy-section.tsx index 5c25572..a254d97 100644 --- a/components/studio/deploy-section.tsx +++ b/components/studio/deploy-section.tsx @@ -191,16 +191,33 @@ export function DeploySection() { salt ); + addLog("info", "Signing... approve in your wallet extension."); + const receipt = await sendTransactionAsync({ extrinsic }); - addLog("success", `Deployed in block: ${receipt.blockHash}`); - if (gasEstimation.deployedAddress) { - addLog("success", `Address: ${gasEstimation.deployedAddress}`); + if (receipt.status === "failed") { + const errorMsg = receipt.errorMessage || (receipt.dispatchError + ? JSON.stringify(receipt.dispatchError) + : "Transaction failed on-chain"); + addLog("error", `Deploy failed: ${errorMsg}`); + } else { + addLog("success", `Deployed in block: ${receipt.blockHash}`); + if (gasEstimation.deployedAddress) { + addLog("success", `Address: ${gasEstimation.deployedAddress}`); + } } } catch (error) { const message = error instanceof Error ? error.message : "Deploy failed"; - addLog("error", message); + const isUserRejection = + message.includes("Cancelled") || + message.includes("Rejected") || + message.includes("User denied") || + message.includes("rejected"); + addLog( + isUserRejection ? "info" : "error", + isUserRejection ? "Transaction cancelled by user" : message + ); } }; From 0914f9dbd2dda2860f6bac23f47d4320e694f67b Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 20 Mar 2026 10:01:56 +0530 Subject: [PATCH 20/20] Make editor and output panel resizable with drag handle Replace the flex-based 50/50 split between Monaco editor and output panel with a vertical ResizablePanelGroup (75/25 default). Users can now drag the handle to resize the editor vs output area. --- components/studio/editor-area.tsx | 77 ++++++++++++++++++------------ components/studio/output-panel.tsx | 3 +- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/components/studio/editor-area.tsx b/components/studio/editor-area.tsx index 8558dd2..d9b9d11 100644 --- a/components/studio/editor-area.tsx +++ b/components/studio/editor-area.tsx @@ -4,6 +4,11 @@ import React from "react"; import dynamic from "next/dynamic"; import { useTheme } from "next-themes"; import { useStudio } from "@/context/studio-provider"; +import { + ResizablePanel, + ResizablePanelGroup, + ResizableHandle, +} from "@/components/ui/resizable"; import { EditorTabs } from "./editor-tabs"; import { OutputPanel } from "./output-panel"; @@ -29,39 +34,49 @@ export function EditorArea() { {/* Tabs */} - {/* Monaco editor */} + {/* Editor + Output — resizable vertical split */}
- {activeFile ? ( - - ) : ( -
- Open a file to start editing -
- )} -
+ + {/* Monaco editor */} + +
+ {activeFile ? ( + + ) : ( +
+ Open a file to start editing +
+ )} +
+
+ - {/* Output panel */} - dispatch({ type: "TOGGLE_OUTPUT" })} - /> + {/* Output panel */} + + dispatch({ type: "TOGGLE_OUTPUT" })} + /> + +
+ ); } diff --git a/components/studio/output-panel.tsx b/components/studio/output-panel.tsx index 2aa8b52..b0a80d3 100644 --- a/components/studio/output-panel.tsx +++ b/components/studio/output-panel.tsx @@ -3,7 +3,6 @@ import React from "react"; import { useContractContext } from "@/context/contract-provider"; import { Check, AlertCircle, AlertTriangle, ChevronDown, ChevronRight, Upload } from "lucide-react"; -import { cn } from "@/lib/utils"; interface OutputPanelProps { visible: boolean; @@ -23,7 +22,7 @@ export function OutputPanel({ visible, onToggle }: OutputPanelProps) { const warningCount = compilation.warnings.length; return ( -
+
{/* Toggle header */}