diff --git a/__tests__/components/builder/extrinsic-builder.test.tsx b/__tests__/components/builder/extrinsic-builder.test.tsx index 75d88d5..eeb2b23 100644 --- a/__tests__/components/builder/extrinsic-builder.test.tsx +++ b/__tests__/components/builder/extrinsic-builder.test.tsx @@ -77,8 +77,69 @@ jest.mock("dedot/utils", () => ({ if (!s) return s; return s.charAt(0).toLowerCase() + s.slice(1); }, + assert: jest.fn(), })); +// Mock gas estimation hook +const mockEstimate = jest.fn(); +const mockGasEstimation = { + estimating: false, + weightRequired: null as any, + storageDeposit: null as any, + gasConsumed: null, + deployedAddress: null, + error: null as string | null, + estimate: mockEstimate, +}; + +jest.mock("@/hooks/use-gas-estimation", () => ({ + useGasEstimation: jest.fn().mockImplementation(() => mockGasEstimation), +})); + +// Mock use-chain-token +jest.mock("@/hooks/use-chain-token", () => ({ + useChainToken: jest.fn().mockReturnValue({ + symbol: "DOT", + decimals: 10, + denominations: [], + existentialDeposit: BigInt(0), + loading: false, + }), +})); + +// Mock fee-display +jest.mock("@/lib/fee-display", () => ({ + formatFee: jest.fn().mockReturnValue("0.001 DOT"), + formatWeight: jest.fn().mockReturnValue("refTime: 1.1K, proofSize: 2.2K"), +})); + +// Mock chain-types +jest.mock("@/lib/chain-types", () => ({ + hasReviveApi: jest.fn().mockReturnValue(false), + GenericChainClient: {} as any, +})); + +// Mock next/link — needed for the Studio link +jest.mock("next/link", () => { + const React = require("react"); + return { + __esModule: true, + default: ({ children, href, ...props }: any) => + React.createElement("a", { href, ...props }, children), + }; +}); + +// Mock lucide-react icons — proxy to actual icons but add our test ones +jest.mock("lucide-react", () => { + const actual = jest.requireActual("lucide-react"); + return { + ...actual, + Loader2: (props: any) => , + Zap: (props: any) => , + ArrowRight: (props: any) => , + }; +}); + import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { useForm, FormProvider } from "react-hook-form"; @@ -199,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", () => { @@ -253,4 +314,145 @@ describe("ExtrinsicBuilder", () => { render(); }).not.toThrow(); }); + + describe("gas estimation for Revive", () => { + const reviveTx = { + meta: { + fields: [ + { name: "value", typeId: 6, typeName: "BalanceOf" }, + { name: "weight_limit", typeId: 10, typeName: "Weight" }, + { name: "storage_deposit_limit", typeId: 6, typeName: "BalanceOf" }, + { name: "code", typeId: 14, typeName: "Vec" }, + { name: "data", typeId: 14, typeName: "Vec" }, + { name: "salt", typeId: 15, typeName: "Option<[u8; 32]>" }, + ], + docs: ["Instantiate a contract with code"], + }, + }; + + beforeEach(() => { + // Override section/method options to include Revive + const { createSectionOptions, createMethodOptions } = require("@/lib/parser"); + createSectionOptions.mockReturnValue([ + { value: 60, text: "Revive", docs: ["Contracts"] }, + { value: 0, text: "System", docs: ["System pallet"] }, + ]); + createMethodOptions.mockReturnValue([ + { value: 0, text: "instantiate_with_code" }, + ]); + }); + + function ReviveTestWrapper({ tx = reviveTx }: { tx?: any }) { + const form = useForm({ + defaultValues: { + section: "60:Revive", + method: "", // useEffect clears this on mount anyway + value: "0", + weight_limit: "", + storage_deposit_limit: "", + code: "0x1234", + data: "0x", + salt: "", + }, + }); + + // Simulate method selection after mount + React.useEffect(() => { + form.setValue("method", "0:instantiate_with_code"); + }, [form]); + + const mockClient = { + metadata: { + latest: { + pallets: [ + { + index: 60, + name: "Revive", + calls: { typeId: 100 }, + docs: ["Contracts"], + }, + ], + }, + }, + registry: { + findCodec: jest.fn(), + findType: jest.fn().mockReturnValue({ + typeDef: { + type: "Struct", + value: { + fields: [ + { name: "refTime", typeId: 6 }, + { name: "proofSize", typeId: 6 }, + ], + }, + }, + }), + }, + tx: { + revive: { + instantiateWithCode: { + meta: reviveTx.meta, + }, + }, + }, + } as any; + + return ( + + ); + } + + it("shows Estimate Gas button when pallet=Revive, method=instantiate_with_code", () => { + render(); + expect(screen.getByText("Estimate Gas")).toBeInTheDocument(); + }); + + it("shows Open in Contract Studio link when Revive selected", () => { + const { container } = render(); + // The link is rendered by next/link mock as an tag + const link = container.querySelector('a[href="/studio"]'); + expect(link).toBeInTheDocument(); + }); + + it("does NOT show Estimate Gas for non-Revive pallet", () => { + const systemTx = { + meta: { + fields: [{ name: "remark", typeId: 14, typeName: "Vec" }], + docs: ["Make a remark"], + }, + }; + + function SystemTestWrapper() { + const form = useForm({ + defaultValues: { + section: "0:System", + method: "", + }, + }); + + const mockClient = { + metadata: { latest: { pallets: [] } }, + registry: { findCodec: jest.fn(), findType: jest.fn() }, + tx: { system: { remark: systemTx } }, + } as any; + + return ( + + ); + } + + render(); + expect(screen.queryByText("Estimate Gas")).not.toBeInTheDocument(); + }); + }); }); diff --git a/__tests__/components/builder/information-pane.test.tsx b/__tests__/components/builder/information-pane.test.tsx index 3701cfe..2ee419b 100644 --- a/__tests__/components/builder/information-pane.test.tsx +++ b/__tests__/components/builder/information-pane.test.tsx @@ -317,4 +317,54 @@ describe("InformationPane", () => { expect(screen.getByTestId("field-hex-dest")).toBeInTheDocument(); expect(screen.getByTestId("field-hex-value")).toBeInTheDocument(); }); + + it("re-encodes when object-valued arg changes (argValuesKey serialization)", () => { + const { encodeAllArgs } = require("@/lib/codec"); + + const mockTx = { + meta: { + index: 1, + fields: [ + { name: "weight_limit", typeId: 10, typeName: "Weight" }, + ], + docs: [], + }, + } as any; + + // First render with object value + const form1 = createMockForm({ + section: "60:Revive", + weight_limit: { refTime: "1000", proofSize: "2000" }, + }); + + const { rerender } = render( + + ); + + const firstCallCount = encodeAllArgs.mock.calls.length; + + // Rerender with different object value + const form2 = createMockForm({ + section: "60:Revive", + weight_limit: { refTime: "3000", proofSize: "4000" }, + }); + + rerender( + + ); + + // encodeAllArgs should have been called again because the argValuesKey + // now uses JSON.stringify for object values instead of "[object Object]" + expect(encodeAllArgs.mock.calls.length).toBeGreaterThan(firstCallCount); + }); }); diff --git a/__tests__/components/params/inputs/struct.test.tsx b/__tests__/components/params/inputs/struct.test.tsx index f15e700..72a7a5b 100644 --- a/__tests__/components/params/inputs/struct.test.tsx +++ b/__tests__/components/params/inputs/struct.test.tsx @@ -113,4 +113,75 @@ describe("Struct", () => { render(); expect(screen.getByText("Missing fields")).toBeInTheDocument(); }); + + it("hydrates child inputs from external value prop", () => { + const onChange = jest.fn(); + const fields = [ + createField("refTime", "Ref Time"), + createField("proofSize", "Proof Size"), + ]; + + const { rerender } = render( + + ); + + // Now set external value — simulating gas estimation auto-fill + rerender( + + ); + + // The child inputs should receive the values via cloneElement + const refTimeInput = screen.getByTestId("field-testStruct-refTime"); + const proofSizeInput = screen.getByTestId("field-testStruct-proofSize"); + + // The TestFieldInput component doesn't render value, but onChange should + // have been called with the synced values. Since useEffect triggers + // setValues but not onChange (only handleFieldChange calls onChange), + // we verify the internal state was updated by checking that a subsequent + // field change includes the externally-set values + fireEvent.change(refTimeInput, { target: { value: "3000" } }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ refTime: "3000", proofSize: "2000" }) + ); + }); + + it("does not loop when external value matches internal state", () => { + const onChange = jest.fn(); + const fields = [createField("a", "A"), createField("b", "B")]; + const value = { a: "1", b: "2" }; + + const { rerender } = render( + + ); + + // Rerender with same value — should not cause extra renders + rerender( + + ); + + // Verify the component didn't explode — no infinite loop + expect(screen.getByText("Transfer Info")).toBeInTheDocument(); + }); + + it("emits object-valued onChange (not string)", () => { + const onChange = jest.fn(); + const fields = [ + createField("x", "X"), + createField("y", "Y"), + ]; + render(); + + const xInput = screen.getByTestId("field-testStruct-x"); + fireEvent.change(xInput, { target: { value: "hello" } }); + + // onChange should receive an object, not a string + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ x: "hello" })); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(typeof lastCall).toBe("object"); + }); }); diff --git a/__tests__/hooks/use-gas-estimation.test.ts b/__tests__/hooks/use-gas-estimation.test.ts new file mode 100644 index 0000000..969a3de --- /dev/null +++ b/__tests__/hooks/use-gas-estimation.test.ts @@ -0,0 +1,203 @@ +jest.mock("../../env.mjs", () => ({ env: {} })); + +jest.mock("../../lib/chain-types", () => ({ + hasReviveApi: jest.fn(), +})); + +import { renderHook, act } from "@testing-library/react"; +import { useGasEstimation } from "../../hooks/use-gas-estimation"; +import { hasReviveApi } from "../../lib/chain-types"; + +const mockHasReviveApi = hasReviveApi as jest.Mock; + +function createMockClient(instantiateResult?: any) { + return { + call: { + reviveApi: { + instantiate: jest.fn().mockResolvedValue( + instantiateResult || { + weightRequired: { refTime: BigInt(1000), proofSize: BigInt(2000) }, + storageDeposit: { type: "Charge", value: BigInt(5000) }, + gasConsumed: BigInt(800), + result: { + isOk: true, + value: { + result: { flags: { bits: 0 }, data: "0x" }, + addr: "0x1234567890abcdef1234567890abcdef12345678", + }, + }, + } + ), + }, + }, + } as any; +} + +describe("useGasEstimation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns error when client is null", async () => { + const { result } = renderHook(() => + useGasEstimation(null, "0xorigin", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toBe("No client connected"); + expect(result.current.weightRequired).toBeNull(); + }); + + it("returns error when client has no reviveApi", async () => { + const client = {} as any; + mockHasReviveApi.mockReturnValue(false); + + const { result } = renderHook(() => + useGasEstimation(client, "0xorigin", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toContain("does not support Revive API"); + }); + + it("returns error when no origin (account)", async () => { + const client = createMockClient(); + mockHasReviveApi.mockReturnValue(true); + + const { result } = renderHook(() => + useGasEstimation(client, "", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toContain("No account connected"); + }); + + it("returns error when no bytecode", async () => { + const client = createMockClient(); + mockHasReviveApi.mockReturnValue(true); + + const { result } = renderHook(() => + useGasEstimation(client, "0xorigin", BigInt(0), "", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toContain("No bytecode"); + }); + + it("applies 10% buffer on successful estimation", async () => { + const client = createMockClient(); + mockHasReviveApi.mockReturnValue(true); + + const { result } = renderHook(() => + useGasEstimation(client, "0xorigin", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toBeNull(); + // 1000 + 10% = 1100 + expect(result.current.weightRequired?.refTime).toBe(BigInt(1100)); + // 2000 + 10% = 2200 + expect(result.current.weightRequired?.proofSize).toBe(BigInt(2200)); + // Storage Charge: 5000 + 10% = 5500 + expect(result.current.storageDeposit?.type).toBe("Charge"); + expect(result.current.storageDeposit?.value).toBe(BigInt(5500)); + expect(result.current.gasConsumed).toBe(BigInt(800)); + expect(result.current.deployedAddress).toBe( + "0x1234567890abcdef1234567890abcdef12345678" + ); + }); + + it("does not buffer Refund storage deposit", async () => { + const client = createMockClient({ + weightRequired: { refTime: BigInt(100), proofSize: BigInt(200) }, + storageDeposit: { type: "Refund", value: BigInt(3000) }, + gasConsumed: BigInt(50), + result: { + isOk: true, + value: { + result: { flags: { bits: 0 }, data: "0x" }, + addr: "0xaddr", + }, + }, + }); + mockHasReviveApi.mockReturnValue(true); + + const { result } = renderHook(() => + useGasEstimation(client, "0xorigin", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.storageDeposit?.type).toBe("Refund"); + // Refund value is NOT buffered + expect(result.current.storageDeposit?.value).toBe(BigInt(3000)); + }); + + it("handles dry-run dispatch error", async () => { + const client = createMockClient({ + weightRequired: { refTime: BigInt(100), proofSize: BigInt(200) }, + storageDeposit: { type: "Charge", value: BigInt(1000) }, + gasConsumed: BigInt(50), + result: { + isOk: false, + err: { type: "Module", value: { index: 8, error: "0x01000000" } }, + }, + }); + mockHasReviveApi.mockReturnValue(true); + + const { result } = renderHook(() => + useGasEstimation(client, "0xorigin", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toContain("Dry-run failed"); + // Failed dry-run must NOT populate estimate state — consumers check + // weightRequired to enable deploy buttons and auto-fill form fields + expect(result.current.weightRequired).toBeNull(); + expect(result.current.storageDeposit).toBeNull(); + expect(result.current.gasConsumed).toBeNull(); + expect(result.current.deployedAddress).toBeNull(); + }); + + it("handles RPC exception", async () => { + const client = { + call: { + reviveApi: { + instantiate: jest.fn().mockRejectedValue(new Error("RPC timeout")), + }, + }, + } as any; + mockHasReviveApi.mockReturnValue(true); + + const { result } = renderHook(() => + useGasEstimation(client, "0xorigin", BigInt(0), "0x1234", "0x") + ); + + await act(async () => { + await result.current.estimate(); + }); + + expect(result.current.error).toBe("RPC timeout"); + expect(result.current.estimating).toBe(false); + }); +}); 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..0f3052a --- /dev/null +++ b/__tests__/studio-reducer.test.ts @@ -0,0 +1,312 @@ +/** + * 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 + }); + + 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", () => { + 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 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, { + 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)); + }); + }); +}); 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/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/app/studio/layout.tsx b/app/studio/layout.tsx new file mode 100644 index 0000000..d0795e1 --- /dev/null +++ b/app/studio/layout.tsx @@ -0,0 +1,14 @@ +import { NavBar } from "@/components/layout/site-header"; + +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..93e0ca3 --- /dev/null +++ b/app/studio/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; +import { ClientProvider } from "@/context/client"; +import { ContractProvider } from "@/context/contract-provider"; +import { StudioProvider } from "@/context/studio-provider"; +import { StudioLayout } from "@/components/studio/studio-layout"; + +export default function StudioPage() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/components/builder/extrinsic-builder.tsx b/components/builder/extrinsic-builder.tsx index 89683de..d8a4414 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; @@ -50,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 @@ -66,6 +73,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) { @@ -114,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 || ""]); @@ -121,17 +200,40 @@ 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 }); - 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"; - 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); } }; @@ -286,16 +388,81 @@ 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} +

+ )} +
+ )} +
diff --git a/components/builder/information-pane.tsx b/components/builder/information-pane.tsx index 4cb0b87..7aec987 100644 --- a/components/builder/information-pane.tsx +++ b/components/builder/information-pane.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; -import { $, DedotClient } from "dedot"; +import { $ } from "dedot"; import { HexString, stringCamelCase, @@ -15,7 +15,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { GenericTxCall } from "dedot/types"; -import { PolkadotApi } from "@dedot/chaintypes"; +import type { GenericChainClient } from "@/lib/chain-types"; import { UseFormReturn } from "react-hook-form"; import { BuilderFormValues } from "@/app/builder/page"; import { @@ -33,7 +33,7 @@ import { Copy, Check, AlertCircle } from "lucide-react"; import { cn } from "@/lib/utils"; interface InformationPaneProps { - client: DedotClient; + client: GenericChainClient; tx: GenericTxCall | null; builderForm: UseFormReturn; onTxChange: (tx: GenericTxCall) => void; @@ -104,7 +104,10 @@ const InformationPane: React.FC = ({ // the relevant arg field values into a stable string for dependency tracking. const formValues = builderForm.watch(); const argFieldNames = tx?.meta?.fields?.map((f) => f.name || "") || []; - const argValuesKey = argFieldNames.map((n) => `${n}:${formValues[n] ?? ""}`).join("|"); + const argValuesKey = argFieldNames.map((n) => { + const v = formValues[n]; + return `${n}:${typeof v === "object" && v !== null ? JSON.stringify(v) : v ?? ""}`; + }).join("|"); // Encode section hex const sectionValue = formValues.section; diff --git a/components/layout/main-nav.tsx b/components/layout/main-nav.tsx index ff52b04..1858cef 100644 --- a/components/layout/main-nav.tsx +++ b/components/layout/main-nav.tsx @@ -34,25 +34,41 @@ export function MainNav({ items, children }: MainNavProps) { {siteConfig.name}
- {items?.length ? ( - - ) : null} + + + {/* 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/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/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/deploy-section.tsx b/components/studio/deploy-section.tsx new file mode 100644 index 0000000..a254d97 --- /dev/null +++ b/components/studio/deploy-section.tsx @@ -0,0 +1,422 @@ +"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 + ); + + addLog("info", "Signing... approve in your wallet extension."); + + const receipt = await sendTransactionAsync({ extrinsic }); + + 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"; + 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 + ); + } + }; + + 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..d9b9d11 --- /dev/null +++ b/components/studio/editor-area.tsx @@ -0,0 +1,82 @@ +"use client"; + +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"; + +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 */} + + + {/* Editor + Output — resizable vertical split */} +
+ + {/* 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..b0a80d3 --- /dev/null +++ b/components/studio/output-panel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import React from "react"; +import { useContractContext } from "@/context/contract-provider"; +import { Check, AlertCircle, AlertTriangle, ChevronDown, ChevronRight, Upload } from "lucide-react"; + +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 */} + +
+ ); +} 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/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..2f55a32 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 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/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/context/contract-provider.tsx b/context/contract-provider.tsx new file mode 100644 index 0000000..6cd5752 --- /dev/null +++ b/context/contract-provider.tsx @@ -0,0 +1,70 @@ +"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: null, + bytecodeSource: null, +}; + +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/context/studio-provider.tsx b/context/studio-provider.tsx new file mode 100644 index 0000000..9b0309f --- /dev/null +++ b/context/studio-provider.tsx @@ -0,0 +1,299 @@ +"use client"; + +import React, { + createContext, + useContext, + useReducer, + useEffect, + useRef, + useCallback, + ReactNode, +} from "react"; +import type { + StudioState, + StudioAction, + StudioFile, +} from "@/types/studio"; +import { + createDefaultState, + getAllSources, + contentHash, +} from "@/types/studio"; + +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 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 + ); + if (nameExists) return state; + + const id = crypto.randomUUID(); + const newFile: StudioFile = { + id, + name: action.name, + content: action.content ?? "", + }; + return { + ...state, + files: { ...state.files, [id]: newFile }, + openTabs: [...state.openTabs, { fileId: id }], + activeTabId: id, + }; + } + + case "RENAME_FILE": { + const file = state.files[action.fileId]; + if (!file) return state; + // 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 + ); + if (nameExists) return state; + + return { + ...state, + files: { + ...state.files, + [action.fileId]: { ...file, name: action.newName }, + }, + }; + } + + case "DELETE_FILE": { + const fileIds = Object.keys(state.files); + // Reject if only one file remains + if (fileIds.length <= 1) return state; + + const { [action.fileId]: _, ...remainingFiles } = state.files; + const newTabs = state.openTabs.filter((t) => t.fileId !== action.fileId); + + // Focus neighbor if active tab was deleted + let newActiveId = state.activeTabId; + if (state.activeTabId === action.fileId) { + const oldIndex = state.openTabs.findIndex( + (t) => t.fileId === action.fileId + ); + if (newTabs.length > 0) { + const neighborIndex = Math.min(oldIndex, newTabs.length - 1); + newActiveId = newTabs[neighborIndex].fileId; + } else { + // No open tabs — pick the first remaining file + newActiveId = Object.keys(remainingFiles)[0] || null; + } + } + + return { + ...state, + files: remainingFiles, + openTabs: newTabs, + activeTabId: newActiveId, + }; + } + + case "UPDATE_FILE_CONTENT": { + const file = state.files[action.fileId]; + if (!file) return state; + return { + ...state, + files: { + ...state.files, + [action.fileId]: { ...file, content: action.content }, + }, + }; + } + + case "IMPORT_FILES": { + let newFiles = { ...state.files }; + let newTabs = [...state.openTabs]; + let firstNewId: string | null = null; + + for (const { name, content } of action.files) { + // Skip duplicates + const exists = Object.values(newFiles).some((f) => f.name === name); + if (exists) continue; + + const id = crypto.randomUUID(); + newFiles[id] = { id, name, content }; + newTabs.push({ fileId: id }); + if (!firstNewId) firstNewId = id; + } + + return { + ...state, + files: newFiles, + openTabs: newTabs, + activeTabId: firstNewId || state.activeTabId, + }; + } + + case "OPEN_TAB": { + // Don't add duplicate tabs + if (state.openTabs.some((t) => t.fileId === action.fileId)) { + return { ...state, activeTabId: action.fileId }; + } + return { + ...state, + openTabs: [...state.openTabs, { fileId: action.fileId }], + activeTabId: action.fileId, + }; + } + + case "CLOSE_TAB": { + const newTabs = state.openTabs.filter( + (t) => t.fileId !== action.fileId + ); + let newActiveId = state.activeTabId; + + if (state.activeTabId === action.fileId) { + const oldIndex = state.openTabs.findIndex( + (t) => t.fileId === action.fileId + ); + if (newTabs.length > 0) { + const neighborIndex = Math.min(oldIndex, newTabs.length - 1); + newActiveId = newTabs[neighborIndex].fileId; + } else { + newActiveId = null; + } + } + + return { + ...state, + openTabs: newTabs, + activeTabId: newActiveId, + }; + } + + case "SET_ACTIVE_TAB": + return { ...state, activeTabId: action.fileId }; + + case "SET_COMPILE_TARGET": + return { ...state, compileTarget: action.target }; + + case "SET_COMPILED_HASH": + return { ...state, compiledContentHash: action.hash }; + + case "TOGGLE_SIDEBAR": + return { ...state, sidebarCollapsed: !state.sidebarCollapsed }; + + case "TOGGLE_OUTPUT": + return { ...state, outputVisible: !state.outputVisible }; + + case "HYDRATE": + return action.state; + + default: + return state; + } +} + +// --- Context --- + +interface StudioContextValue { + state: StudioState; + dispatch: React.Dispatch; + activeFile: StudioFile | null; + allSources: Record; + isDirtySinceCompile: boolean; + currentContentHash: string; +} + +const StudioContext = createContext(null); + +export function useStudio(): StudioContextValue { + const ctx = useContext(StudioContext); + if (!ctx) throw new Error("useStudio must be used within StudioProvider"); + return ctx; +} + +// --- Provider --- + +export function StudioProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(studioReducer, null, () => { + // Try hydrating from sessionStorage + if (typeof window === "undefined") return createDefaultState(); + try { + const stored = sessionStorage.getItem(SESSION_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Basic validation + if (parsed.files && parsed.openTabs) { + return { ...createDefaultState(), ...parsed } as StudioState; + } + } + } catch { + // Fall through to default + } + return createDefaultState(); + }); + + // Debounced sessionStorage persistence + const persistTimerRef = useRef | null>(null); + useEffect(() => { + if (persistTimerRef.current) clearTimeout(persistTimerRef.current); + persistTimerRef.current = setTimeout(() => { + try { + const serialized = JSON.stringify({ + files: state.files, + openTabs: state.openTabs, + activeTabId: state.activeTabId, + sidebarCollapsed: state.sidebarCollapsed, + outputVisible: state.outputVisible, + compileTarget: state.compileTarget, + // Don't persist compiledContentHash — artifacts aren't persisted + }); + if (serialized.length > SIZE_WARN_BYTES) { + console.warn( + `[Studio] Workspace state is ${(serialized.length / 1024).toFixed(1)}KB` + ); + } + sessionStorage.setItem(SESSION_KEY, serialized); + } catch { + // sessionStorage full or unavailable + } + }, PERSIST_DEBOUNCE_MS); + return () => { + if (persistTimerRef.current) clearTimeout(persistTimerRef.current); + }; + }, [state]); + + // Derived values + const activeFile = state.activeTabId + ? state.files[state.activeTabId] ?? null + : null; + + const allSources = getAllSources(state.files); + const currentContentHash = contentHash(allSources); + + const isDirtySinceCompile = + state.compiledContentHash === null || + state.compiledContentHash !== currentContentHash; + + const value: StudioContextValue = { + state, + dispatch, + activeFile, + allSources, + isDirtySinceCompile, + currentContentHash, + }; + + 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-gas-estimation.ts b/hooks/use-gas-estimation.ts new file mode 100644 index 0000000..cea022d --- /dev/null +++ b/hooks/use-gas-estimation.ts @@ -0,0 +1,168 @@ +"use client"; + +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"; + +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); + + // 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 = { origin, value, code, data, salt }; + setWeightRequired(null); + setStorageDeposit(null); + setGasConsumed(null); + setDeployedAddress(null); + setError(null); + } + }, [origin, value, code, data, salt]); + + 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/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/jest.polyfills.ts b/jest.polyfills.ts index ba4e062..a652f38 100644 --- a/jest.polyfills.ts +++ b/jest.polyfills.ts @@ -1,7 +1,18 @@ import { TextDecoder, TextEncoder } from "util"; +import { randomUUID } from "crypto"; Object.assign(global, { TextDecoder, TextEncoder }); +// crypto.randomUUID polyfill for jsdom +if (!globalThis.crypto?.randomUUID) { + Object.defineProperty(globalThis, "crypto", { + value: { + ...globalThis.crypto, + randomUUID, + }, + }); +} + // ResizeObserver polyfill for cmdk (used by shadcn Command component) global.ResizeObserver = class ResizeObserver { observe() {} diff --git a/lib/chain-types.ts b/lib/chain-types.ts new file mode 100644 index 0000000..eb18540 --- /dev/null +++ b/lib/chain-types.ts @@ -0,0 +1,36 @@ +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 { + // Dedot uses proxy chains for runtime API access — merely accessing + // `client.call.reviveApi.instantiate` triggers an API spec lookup that + // throws UnknownApiError on chains without ReviveApi. Use try/catch. + try { + return typeof (client as any).call?.reviveApi?.instantiate === "function"; + } catch { + return false; + } +} diff --git a/lib/compile-client.ts b/lib/compile-client.ts new file mode 100644 index 0000000..6510cb9 --- /dev/null +++ b/lib/compile-client.ts @@ -0,0 +1,128 @@ +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 || [], + }; +} + +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/contract-store.ts b/lib/contract-store.ts index 3df9cc9..cc9a8ee 100644 --- a/lib/contract-store.ts +++ b/lib/contract-store.ts @@ -17,7 +17,8 @@ export interface ContractCompilationState { errors: CompilationError[]; warnings: CompilationError[]; isCompiling: boolean; - mode: "evm" | "pvm"; + mode: "evm" | "pvm" | null; + bytecodeSource: "compile" | "upload" | null; } const INITIAL_STATE: ContractCompilationState = { @@ -29,7 +30,8 @@ const INITIAL_STATE: ContractCompilationState = { errors: [], warnings: [], isCompiling: false, - mode: "pvm", + mode: null, + bytecodeSource: null, }; let state: ContractCompilationState = { ...INITIAL_STATE }; 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(); +} diff --git a/lib/import-resolver.ts b/lib/import-resolver.ts index aed6a29..27e41ac 100644 --- a/lib/import-resolver.ts +++ b/lib/import-resolver.ts @@ -216,6 +216,84 @@ 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: 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)) { + 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. * 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); } diff --git a/package.json b/package.json index 1d285eb..2674995 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@parity/resolc": "^1.0.0", "@polkadot/ui-shared": "^3.16.4", "@polkadot/util-crypto": "^14.0.1", - "@radix-ui/react-accordion": "^1.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-aspect-ratio": "^1.1.1", "@radix-ui/react-avatar": "^1.1.2", @@ -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/types/studio.ts b/types/studio.ts new file mode 100644 index 0000000..37cbe3a --- /dev/null +++ b/types/studio.ts @@ -0,0 +1,97 @@ +export interface StudioFile { + id: string; + name: string; // e.g., "MyToken.sol" + content: string; +} + +export interface OpenTab { + fileId: string; +} + +export interface StudioState { + files: Record; // keyed by ID + openTabs: OpenTab[]; + activeTabId: string | null; + sidebarCollapsed: boolean; + outputVisible: boolean; + compileTarget: "evm" | "pvm"; // user's INTENT (what they'll compile next) + compiledContentHash: string | null; // hash of all sources at last successful compile +} + +export type StudioAction = + | { type: "CREATE_FILE"; name: string; content?: string } + | { type: "RENAME_FILE"; fileId: string; newName: string } + | { type: "DELETE_FILE"; fileId: string } + | { type: "UPDATE_FILE_CONTENT"; fileId: string; content: string } + | { type: "IMPORT_FILES"; files: Array<{ name: string; content: string }> } + | { type: "OPEN_TAB"; fileId: string } + | { type: "CLOSE_TAB"; fileId: string } + | { type: "SET_ACTIVE_TAB"; fileId: string } + | { type: "SET_COMPILE_TARGET"; target: "evm" | "pvm" } + | { type: "SET_COMPILED_HASH"; hash: string } + | { type: "TOGGLE_SIDEBAR" } + | { type: "TOGGLE_OUTPUT" } + | { type: "HYDRATE"; state: StudioState }; + +// --- Content hash utility --- + +/** + * Fast non-crypto hash (djb2) for content equality comparison. + * Hashes sorted file names + contents. + */ +export function contentHash( + sources: Record +): string { + const keys = Object.keys(sources).sort(); + let hash = 5381; + for (const key of keys) { + const str = key + "\0" + sources[key].content; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + } + return hash.toString(36); +} + +export 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 createDefaultState(): StudioState { + const id = crypto.randomUUID(); + return { + files: { + [id]: { id, name: "Contract.sol", content: DEFAULT_SOLIDITY }, + }, + openTabs: [{ fileId: id }], + activeTabId: id, + sidebarCollapsed: false, + outputVisible: true, + compileTarget: "pvm", + compiledContentHash: null, + }; +} + +/** + * Build allSources map (fileName → {content}) for compile API. + */ +export function getAllSources( + files: Record +): Record { + const sources: Record = {}; + for (const file of Object.values(files)) { + sources[file.name] = { content: file.content }; + } + return sources; +} diff --git a/yarn.lock b/yarn.lock index e7a45c9..273ba4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1796,9 +1796,9 @@ resolved "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz" integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg== -"@radix-ui/react-accordion@^1.2.12", "@radix-ui/react-accordion@^1.2.2": +"@radix-ui/react-accordion@^1.2.12": version "1.2.12" - resolved "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz" + resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz#1fd70d4ef36018012b9e03324ff186de7a29c13f" integrity sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA== dependencies: "@radix-ui/primitive" "1.1.3" @@ -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"