diff --git a/src/assets/traces-example.json b/src/assets/traces-example.json new file mode 100644 index 00000000..ee01b80c --- /dev/null +++ b/src/assets/traces-example.json @@ -0,0 +1,39 @@ +{ + "$schema": "./traces-schema.json", + "initial-pc": 5, + "initial-gas": "0x1234", + "initial-args": "0x1234", + "expected-gas": "0x0", + "expected-status": "halt", + "spi-program": "0x1234", + "host-calls-trace": [ + { + "ecalli": 10, + "pc": 1234, + "before": { + "gas": "0x1234", + "regs": { + "7": "0x1234" + }, + "memory": [ + { + "address": 65539, + "contents": "0x04" + } + ] + }, + "after": { + "gas": "0x1230", + "regs": { + "7": "0x1234" + }, + "memory": [ + { + "address": 65538, + "contents": "0x04" + } + ] + } + } + ] +} diff --git a/src/assets/traces-schema.json b/src/assets/traces-schema.json new file mode 100644 index 00000000..c541e2b0 --- /dev/null +++ b/src/assets/traces-schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PVM Debugger Trace", + "description": "Schema for PVM debugger trace files", + "type": "object", + "required": ["initial-pc", "initial-gas", "initial-args", "host-calls-trace"], + "properties": { + "initial-pc": { + "type": "integer", + "description": "Initial program counter value" + }, + "initial-gas": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Initial gas amount as hexadecimal string (u64)" + }, + "initial-args": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Initial arguments as hexadecimal string (bytes)" + }, + "expected-gas": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Expected remaining gas as hexadecimal string (u64)" + }, + "expected-status": { + "type": "string", + "enum": ["panic", "halt", "page-fault"], + "description": "Expected program execution status" + }, + "spi-program": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "SPI program data as hexadecimal string (bytes)" + }, + "host-calls-trace": { + "type": "array", + "description": "Array of host call trace entries", + "items": { + "type": "object", + "required": ["after"], + "properties": { + "ecalli": { + "type": "integer", + "description": "ECALLI instruction identifier" + }, + "pc": { + "type": "integer", + "description": "Program counter value at the time of the call" + }, + "before": { + "$ref": "#/definitions/vmState", + "description": "VM state before the host call" + }, + "after": { + "$ref": "#/definitions/vmState", + "description": "VM state after the host call" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "definitions": { + "vmState": { + "type": "object", + "required": ["gas", "regs", "memory"], + "properties": { + "gas": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Gas amount as hexadecimal string (u64)" + }, + "regs": { + "type": "object", + "patternProperties": { + "^[0-9]+$": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Register value as hexadecimal string (u64)" + } + }, + "additionalProperties": false, + "description": "Register values indexed by register number" + }, + "memory": { + "type": "array", + "description": "Memory contents at specific addresses", + "items": { + "type": "object", + "required": ["address", "contents"], + "properties": { + "address": { + "type": "integer", + "minimum": 0, + "description": "Memory address" + }, + "contents": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$", + "description": "Memory contents as hexadecimal string (bytes)" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } +} diff --git a/src/components/DebuggerSettings/Content.tsx b/src/components/DebuggerSettings/Content.tsx index 3fcaec65..d688e7a8 100644 --- a/src/components/DebuggerSettings/Content.tsx +++ b/src/components/DebuggerSettings/Content.tsx @@ -11,6 +11,8 @@ import { logger } from "@/utils/loggerService"; import { ToggleDarkMode } from "@/packages/ui-kit/DarkMode/ToggleDarkMode"; import { Separator } from "../ui/separator"; import { WithHelp } from "../WithHelp/WithHelp"; +import { TracesFileManager } from "../TracesFileManager"; +import { cn } from "@/lib/utils"; function stringToNumber(value: string, cb: (x: string) => T): T { try { @@ -105,7 +107,7 @@ export const DebuggerSettingsContent = () => { -
TODO
+
@@ -113,7 +115,7 @@ export const DebuggerSettingsContent = () => { JAM SPI arguments { const value = e.target?.value; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 815ba3d2..8c7b91b5 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -2,6 +2,7 @@ import { Header as FluffyHeader } from "@/packages/ui-kit/Header"; import { DebuggerSettings } from "../DebuggerSettings"; import { PvmSelect } from "../PvmSelect"; import { NumeralSystemSwitch } from "../NumeralSystemSwitch"; +import { HostCallStatus } from "../HostCallStatus"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator } from "../ui/dropdown-menu"; import { Button } from "../ui/button"; import { EllipsisVertical } from "lucide-react"; @@ -13,6 +14,10 @@ const EndSlot = () => { return (
+
+ +
+
diff --git a/src/components/HostCallStatus/HostCallStatus.tsx b/src/components/HostCallStatus/HostCallStatus.tsx new file mode 100644 index 00000000..1cd22692 --- /dev/null +++ b/src/components/HostCallStatus/HostCallStatus.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useAppSelector } from "@/store/hooks"; +import { selectHostCallsTrace, selectCurrentHostCallIndex } from "@/store/debugger/debuggerSlice"; +import { Activity, CheckCircle } from "lucide-react"; + +export const HostCallStatus: React.FC = () => { + const tracesFile = useAppSelector(selectHostCallsTrace); + const currentIndex = useAppSelector(selectCurrentHostCallIndex); + + if (!tracesFile || tracesFile["host-calls-trace"].length === 0) { + return null; + } + + const totalHostCalls = tracesFile["host-calls-trace"].length; + const isComplete = currentIndex >= totalHostCalls; + const progress = Math.min((currentIndex / totalHostCalls) * 100, 100); + + return ( +
+
+ {isComplete ? ( + + ) : ( + + )} + + Host Calls: {currentIndex}/{totalHostCalls} + +
+ + {/* Progress bar */} +
+
+
+ + {/* Status text */} + {isComplete ? "Complete" : "In Progress"} +
+ ); +}; diff --git a/src/components/HostCallStatus/index.ts b/src/components/HostCallStatus/index.ts new file mode 100644 index 00000000..a6524897 --- /dev/null +++ b/src/components/HostCallStatus/index.ts @@ -0,0 +1 @@ +export { HostCallStatus } from "./HostCallStatus"; diff --git a/src/components/ProgramLoader/Loader.tsx b/src/components/ProgramLoader/Loader.tsx index a7616e25..457aecc2 100644 --- a/src/components/ProgramLoader/Loader.tsx +++ b/src/components/ProgramLoader/Loader.tsx @@ -14,6 +14,7 @@ import { Separator } from "../ui/separator"; import { TriangleAlert } from "lucide-react"; import { WithHelp } from "../WithHelp/WithHelp"; import { Input } from "../ui/input"; +import { TracesFileManager } from "../TracesFileManager"; export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) => void }) => { const dispatch = useAppDispatch(); @@ -22,6 +23,7 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) = const debuggerActions = useDebuggerActions(); const isLoading = useAppSelector(selectIsAnyWorkerLoading); const debuggerState = useAppSelector((state) => state.debugger); + const navigate = useNavigate(); useEffect(() => { @@ -35,7 +37,14 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) = dispatch(setIsProgramEditMode(false)); try { - await debuggerActions.handleProgramLoad(program || programLoad); + const loadedProgram = program || programLoad; + + // Load traces file if present + if (loadedProgram?.tracesFile) { + debuggerActions.handleTracesLoad(loadedProgram.tracesFile); + } + + await debuggerActions.handleProgramLoad(loadedProgram); setIsDialogOpen?.(false); navigate("/", { replace: true }); } catch (error) { @@ -110,8 +119,32 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) = Host Calls Trace -

(coming soon)

+
+ {programLoad.tracesFile ? ( + {programLoad.tracesFile["host-calls-trace"].length} host call(s) + ) : ( + + )} +
+
+ + )} + {programLoad.tracesFile && ( + <> +
+ Initial PC: + {programLoad.tracesFile["initial-pc"]} +
+
+ Initial Gas: + {programLoad.tracesFile["initial-gas"]}
+ {programLoad.tracesFile["expected-status"] && ( +
+ Expected Status: + {programLoad.tracesFile["expected-status"]} +
+ )} )}
diff --git a/src/components/ProgramLoader/ProgramFileUpload.tsx b/src/components/ProgramLoader/ProgramFileUpload.tsx index fc056bb0..bd30a8ec 100644 --- a/src/components/ProgramLoader/ProgramFileUpload.tsx +++ b/src/components/ProgramLoader/ProgramFileUpload.tsx @@ -1,9 +1,10 @@ import { useDropzone } from "react-dropzone"; import { ProgramUploadFileInput, ProgramUploadFileOutput } from "./types"; -import { mapUploadFileInputToOutput } from "./utils"; +import { mapUploadFileInputToOutput, mapTracesFileToOutput } from "./utils"; import { bytes, ProgramDecoder } from "@typeberry/pvm-debugger-adapter"; import { ExpectedState, MemoryChunkItem, PageMapItem, RegistersArray } from "@/types/pvm.ts"; -import { SafeParseReturnType, z } from "zod"; +import { SafeParseReturnType, z, ZodError } from "zod"; +import { validateTracesFile } from "@/types/type-guards"; import { useAppSelector } from "@/store/hooks"; import { selectInitialState } from "@/store/debugger/debuggerSlice"; import { cn, getAsChunks, getAsPageMap } from "@/lib/utils.ts"; @@ -53,6 +54,10 @@ const validateJsonTestCaseSchema = (json: RawProgramUploadFileInput): Validation return schema.safeParse(json); }; +const validateTracesFileSchema = (json: unknown) => { + return validateTracesFile(json); +}; + const generateErrorMessageFromZodValidation = (result: ValidationResult): string => { if (!result.error) return "Unknown error occurred"; @@ -64,6 +69,17 @@ const generateErrorMessageFromZodValidation = (result: ValidationResult): string return `File validation failed with the following errors:\n\n${formattedErrors.join("\n")}`; }; +const generateErrorMessageFromTracesValidation = (error: ZodError): string => { + if (!error?.issues) return "Invalid traces file format"; + + const formattedErrors = error.issues.map((issue) => { + const path = issue.path.join(" > ") || "root"; + return `Field: "${path}" - ${issue.message}`; + }); + + return `Traces file validation failed with the following errors:\n\n${formattedErrors.join("\n")}`; +}; + function loadFileFromUint8Array( loadedFileName: string, uint8Array: Uint8Array, @@ -101,15 +117,29 @@ function loadFileFromUint8Array( } if (jsonFile !== null) { + // First try to validate as traces file + const tracesResult = validateTracesFileSchema(jsonFile); + if (tracesResult.success) { + onFileUpload(mapTracesFileToOutput(tracesResult.data, loadedFileName)); + return; + } + + // Then try to validate as JSON test case const result = validateJsonTestCaseSchema(jsonFile); - if (!result.success) { - console.warn("not a valid JSON", result.error); - const errorMessage = generateErrorMessageFromZodValidation(result); - setError(errorMessage || ""); + if (result.success) { + onFileUpload(mapUploadFileInputToOutput(jsonFile, "JSON test")); return; } - onFileUpload(mapUploadFileInputToOutput(jsonFile, "JSON test")); + // If both validations fail, show the more relevant error + // Prioritize traces error if the file has traces-like structure + if (typeof jsonFile === "object" && jsonFile !== null && "host-calls-trace" in jsonFile) { + const errorMessage = generateErrorMessageFromTracesValidation(tracesResult.error); + setError(errorMessage || "Invalid traces file format"); + } else { + const errorMessage = generateErrorMessageFromZodValidation(result); + setError(errorMessage || "Invalid JSON test case format"); + } return; } @@ -136,6 +166,8 @@ function loadFileFromUint8Array( pageMap, memory: chunks, gas: 10000n, + heapStart: memory.sbrkIndex, + heapEnd: memory.heapEnd, }, }); return; @@ -225,7 +257,11 @@ export const ProgramFileUpload: React.FC = ({ isError, o const { getRootProps, getInputProps, open } = useDropzone({ onDrop, - accept: { "application/octet-stream": [".bin", ".pvm"], "application/json": [".json"] }, + accept: { + "application/octet-stream": [".bin", ".pvm"], + "application/json": [".json"], + "text/json": [".json"], + }, noClick: true, }); diff --git a/src/components/ProgramLoader/types.ts b/src/components/ProgramLoader/types.ts index 40e45841..d6ba8108 100644 --- a/src/components/ProgramLoader/types.ts +++ b/src/components/ProgramLoader/types.ts @@ -1,4 +1,4 @@ -import { InitialState } from "@/types/pvm"; +import { InitialState, TracesFile } from "@/types/pvm"; export type ProgramUploadFileOutput = { name: string; @@ -7,6 +7,7 @@ export type ProgramUploadFileOutput = { exampleName?: string; kind: string; isSpi: boolean; + tracesFile?: TracesFile; }; export type ProgramUploadFileInput = { diff --git a/src/components/ProgramLoader/utils.ts b/src/components/ProgramLoader/utils.ts index ffba575f..786c9e71 100644 --- a/src/components/ProgramLoader/utils.ts +++ b/src/components/ProgramLoader/utils.ts @@ -1,6 +1,7 @@ import { mapKeys, camelCase, pickBy } from "lodash"; import { ProgramUploadFileInput, ProgramUploadFileOutput } from "./types"; -import { RegistersArray } from "@/types/pvm"; +import { InitialState, RegistersArray, TracesFile } from "@/types/pvm"; +import { bytes } from "@typeberry/pvm-debugger-adapter"; export function mapUploadFileInputToOutput(data: ProgramUploadFileInput, kind: string): ProgramUploadFileOutput { const camelCasedData = mapKeys(data, (_value: unknown, key: string) => camelCase(key)); @@ -11,9 +12,7 @@ export function mapUploadFileInputToOutput(data: ProgramUploadFileInput, kind: s const result: ProgramUploadFileOutput = { name: data.name, initial: { - ...(mapKeys(initial, (_value: unknown, key) => - camelCase(key.replace("initial", "")), - ) as ProgramUploadFileOutput["initial"]), + ...(mapKeys(initial, (_value: unknown, key) => camelCase(key.replace("initial", ""))) as InitialState), // TODO [ToDr] is this okay? pageMap: data["initial-page-map"].map((val) => ({ address: val.address, @@ -30,3 +29,47 @@ export function mapUploadFileInputToOutput(data: ProgramUploadFileInput, kind: s result.initial.regs = result.initial.regs?.map((x) => BigInt(x as number | bigint)) as RegistersArray; return result; } + +export function mapTracesFileToOutput(tracesFile: TracesFile, fileName: string): ProgramUploadFileOutput { + // Parse initial gas from hex string + const initialGas = BigInt(tracesFile["initial-gas"]); + + // Extract program from spi-program if available + let program: number[] = []; + if (tracesFile["spi-program"]) { + try { + const programBlob = bytes.BytesBlob.parseBlob(tracesFile["spi-program"]); + program = Array.from(programBlob.raw); + } catch (error) { + console.warn("Failed to parse spi-program from traces:", error); + } + } + + // Default registers array + const defaultRegs: RegistersArray = [0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n]; + + const result: ProgramUploadFileOutput = { + name: `${fileName} [traces]`, + initial: { + regs: defaultRegs, + pc: tracesFile["initial-pc"], + pageMap: [], + memory: [], + gas: initialGas, + }, + kind: "Host Calls Trace", + isSpi: !!tracesFile["spi-program"], + program, + tracesFile, + }; + + return result; +} + +export function hexStringToNumber(hexStr: string): number { + return parseInt(hexStr, 16); +} + +export function hexStringToBigInt(hexStr: string): bigint { + return BigInt(hexStr); +} diff --git a/src/components/TracesFileManager/TracesFileManager.tsx b/src/components/TracesFileManager/TracesFileManager.tsx new file mode 100644 index 00000000..ad596bde --- /dev/null +++ b/src/components/TracesFileManager/TracesFileManager.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { Button } from "../ui/button"; +import { UploadCloud, X, FileText } from "lucide-react"; +import { validateTracesFile } from "@/types/type-guards"; +import { useAppSelector } from "@/store/hooks"; +import { selectHostCallsTrace } from "@/store/debugger/debuggerSlice"; +import { useDebuggerActions } from "@/hooks/useDebuggerActions"; +import { cn } from "@/lib/utils"; + +interface TracesFileManagerProps { + className?: string; + /** Whether to use compact layout for smaller spaces */ + compact?: boolean; + /** Whether to show the clear button inline or as a separate action */ + inlineClear?: boolean; +} + +export const TracesFileManager: React.FC = ({ + className, + compact = false, + inlineClear = false, +}) => { + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + const currentTraces = useAppSelector(selectHostCallsTrace); + const { handleTracesLoad, resetHostCallIndex } = useDebuggerActions(); + + const handleFileRead = useCallback( + (e: ProgressEvent) => { + setIsLoading(true); + setError(undefined); + + try { + const content = e.target?.result; + if (typeof content !== "string") { + throw new Error("Failed to read file content"); + } + + const jsonData = JSON.parse(content); + const validation = validateTracesFile(jsonData); + + if (!validation.success) { + const formattedErrors = validation.error.issues + .map((issue) => { + const path = issue.path.join(" > ") || "root"; + return `${path}: ${issue.message}`; + }) + .join("\n"); + + throw new Error(`Invalid traces file:\n${formattedErrors}`); + } + + handleTracesLoad(validation.data); + resetHostCallIndex(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error occurred"; + setError(message); + } finally { + setIsLoading(false); + } + }, + [handleTracesLoad, resetHostCallIndex], + ); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) return; + + const file = acceptedFiles[0]; + const fileReader = new FileReader(); + fileReader.onloadend = (e) => handleFileRead(e); + fileReader.readAsText(file); + }, + [handleFileRead], + ); + + const handleClearTraces = useCallback(() => { + handleTracesLoad(null); + setError(undefined); + }, [handleTracesLoad]); + + const { getRootProps, getInputProps, open, isDragActive } = useDropzone({ + onDrop, + accept: { + "application/json": [".json"], + "text/json": [".json"], + }, + multiple: false, + noClick: true, + }); + + const hasTraces = currentTraces !== null; + + if (hasTraces) { + if (compact) { + // Compact layout for loader + return ( +
+
+ + {currentTraces["host-calls-trace"].length} host call(s) +
+ {inlineClear && ( + + )} +
+ ); + } + + // Full layout for settings + return ( +
+
+
+ + Loaded {currentTraces["host-calls-trace"].length} host call(s) +
+ +
+
+ ); + } + + if (compact) { + // Compact upload layout for loader + return ( +
+
+ +
+
+ + {isDragActive ? "Drop here" : "Drop traces JSON"} +
+ +
+
+ {error && ( +
+
{error}
+
+ )} +
+ ); + } + + // Full upload layout for settings + return ( +
+
+ +
+
+ +

{isDragActive ? "Drop here" : "Drop traces JSON"}

+
+ +
+
+ {error && ( +
+
{error}
+
+ )} +
+ ); +}; diff --git a/src/components/TracesFileManager/index.ts b/src/components/TracesFileManager/index.ts new file mode 100644 index 00000000..d850f6c2 --- /dev/null +++ b/src/components/TracesFileManager/index.ts @@ -0,0 +1 @@ +export { TracesFileManager } from "./TracesFileManager"; diff --git a/src/hooks/useDebuggerActions.ts b/src/hooks/useDebuggerActions.ts index ded57104..ae41809a 100644 --- a/src/hooks/useDebuggerActions.ts +++ b/src/hooks/useDebuggerActions.ts @@ -14,6 +14,9 @@ import { setPvmInitialized, setIsStepMode, setPvmLoaded, + setHostCallsTrace, + setCurrentHostCallIndex, + incrementHostCallIndex, } from "@/store/debugger/debuggerSlice"; import { useAppDispatch, useAppSelector } from "@/store/hooks"; import { @@ -29,7 +32,7 @@ import { syncMemoryRangeAllWorkers, WorkerState, } from "@/store/workers/workersSlice"; -import { AvailablePvms, ExpectedState, Status } from "@/types/pvm"; +import { AvailablePvms, ExpectedState, Status, TracesFile } from "@/types/pvm"; import { logger } from "@/utils/loggerService"; import { useCallback } from "react"; @@ -204,11 +207,29 @@ export const useDebuggerActions = () => { [dispatch, initialState, restartProgram, workers], ); + const handleTracesLoad = useCallback( + (tracesFile: TracesFile | null) => { + dispatch(setHostCallsTrace(tracesFile)); + }, + [dispatch], + ); + + const handleHostCallComplete = useCallback(() => { + dispatch(incrementHostCallIndex()); + }, [dispatch]); + + const resetHostCallIndex = useCallback(() => { + dispatch(setCurrentHostCallIndex(0)); + }, [dispatch]); + return { startProgram, restartProgram, handleProgramLoad, handleBreakpointClick, handlePvmTypeChange, + handleTracesLoad, + handleHostCallComplete, + resetHostCallIndex, }; }; diff --git a/src/packages/web-worker/command-handlers/host-call.ts b/src/packages/web-worker/command-handlers/host-call.ts index ddda1b3a..100f5fae 100644 --- a/src/packages/web-worker/command-handlers/host-call.ts +++ b/src/packages/web-worker/command-handlers/host-call.ts @@ -1,23 +1,11 @@ import { CommandStatus, PvmApiInterface } from "../types"; -import { - HostCallRegisters, - Result, - interpreter, - numbers, - Registers, - IHostCallMemory, - IHostCallRegisters, - OK, - HostCallMemory, -} from "@typeberry/pvm-debugger-adapter"; -import { isInternalPvm } from "../utils"; -import { WasmPvmShellInterface } from "../wasmBindgenShell"; - -const { tryAsU64 } = numbers; +import { TracesFile, TraceVmState } from "@/types/pvm"; type HostCallParams = { pvm: PvmApiInterface | null; hostCallIdentifier: number; + tracesFile?: TracesFile | null; + currentHostCallIndex?: number; }; type HostCallResponse = @@ -32,90 +20,198 @@ type HostCallResponse = error?: unknown; }; -class SimpleRegisters implements IHostCallRegisters { - flatRegisters!: Uint8Array; - pvm!: WasmPvmShellInterface; +// Removed unused classes and functions - get(registerIndex: number): numbers.U64 { - return tryAsU64(new BigUint64Array(this.flatRegisters.buffer)[registerIndex]); - } +const validateVmState = async ( + pvm: PvmApiInterface, + expectedState: TraceVmState, +): Promise<{ isValid: boolean; errors: string[] }> => { + const errors: string[] = []; - set(registerIndex: number, value: numbers.U64): void { - new BigUint64Array(this.flatRegisters.buffer)[registerIndex] = value; - this.pvm.setRegisters(this.flatRegisters); - } -} + try { + // Validate gas if specified + if (expectedState.gas) { + const currentGas = await pvm.getGasLeft(); + const expectedGas = BigInt(expectedState.gas); + if (currentGas !== expectedGas) { + errors.push(`Gas mismatch: expected ${expectedGas}, got ${currentGas}`); + } + } -const getRegisters = (pvm: PvmApiInterface) => { - if (isInternalPvm(pvm)) { - const regs = pvm.getInterpreter().getRegisters(); - return new HostCallRegisters(regs); - } + // Validate registers if specified + if (expectedState.regs) { + const currentRegs = pvm.getRegisters(); + const currentRegsBigInt = new BigUint64Array(currentRegs.buffer); - const registers = new SimpleRegisters(); - const regs = new Registers(); - const rawRegs = new BigUint64Array(pvm.getRegisters().buffer); - regs.copyFrom(rawRegs); - registers.flatRegisters = pvm.getRegisters(); - registers.pvm = pvm; + for (const [regIndex, expectedValue] of Object.entries(expectedState.regs)) { + const index = parseInt(regIndex); + const expected = BigInt(expectedValue); + const current = currentRegsBigInt[index]; - return registers; -}; + if (current !== expected) { + errors.push(`Register ${index} mismatch: expected ${expected}, got ${current}`); + } + } + } -class SimpleMemory implements IHostCallMemory { - pvm!: WasmPvmShellInterface; - memorySize: number = 4096; + // Validate memory if specified + if (expectedState.memory && expectedState.memory.length > 0) { + for (const memEntry of expectedState.memory) { + const address = memEntry.address; + const expectedBytes = new Uint8Array(Buffer.from(memEntry.contents.slice(2), "hex")); + + try { + // Get current memory at address + const pageSize = 4096; // Standard page size + const pageIndex = Math.floor(address / pageSize); + const offsetInPage = address % pageSize; + const currentPage = pvm.getPageDump(pageIndex); + if (!currentPage) { + errors.push(`Failed to read memory page ${pageIndex} at address ${address}`); + continue; + } + const currentBytes = currentPage.slice(offsetInPage, offsetInPage + expectedBytes.length); + + for (let i = 0; i < expectedBytes.length; i++) { + if (currentBytes[i] !== expectedBytes[i]) { + errors.push( + `Memory at address ${address + i} mismatch: expected 0x${expectedBytes[i].toString(16).padStart(2, "0")}, got 0x${currentBytes[i].toString(16).padStart(2, "0")}`, + ); + } + } + } catch (memError) { + errors.push(`Failed to read memory at address ${address}: ${memError}`); + } + } + } + } catch (error) { + errors.push(`Validation failed: ${error}`); + } - loadInto(result: Uint8Array, startAddress: numbers.U64) { - const memoryDump = this.pvm.getPageDump(Number(startAddress / tryAsU64(this.memorySize))); - result.set(memoryDump.subarray(0, result.length)); + return { isValid: errors.length === 0, errors }; +}; - return Result.ok(OK); +const applyVmState = async (pvm: PvmApiInterface, newState: TraceVmState): Promise => { + // Apply gas changes + if (newState.gas) { + const newGas = BigInt(newState.gas); + if (pvm.setGasLeft) { + pvm.setGasLeft(newGas); + } } - storeFrom(address: numbers.U64, bytes: Uint8Array) { - // TODO [ToDr] Either change the API to require handling multi-page writes or change this code to split the write into multiple pages. - this.pvm.setMemory(Number(address), bytes); - return Result.ok(OK); - } + // Apply register changes + if (newState.regs) { + const currentRegs = pvm.getRegisters(); + const regsBigInt = new BigUint64Array(currentRegs.buffer); - getMemory() { - // TODO [MaSi]: This function is used only by `peek` and `poke` host calls, so dummy implementation is okay for now. - return new interpreter.Memory(); - } -} + for (const [regIndex, newValue] of Object.entries(newState.regs)) { + const index = parseInt(regIndex); + const value = BigInt(newValue); + regsBigInt[index] = value; + } -const getMemory = (pvm: PvmApiInterface) => { - if (isInternalPvm(pvm)) { - return new HostCallMemory(pvm.getInterpreter().getMemory()); + pvm.setRegisters(new Uint8Array(regsBigInt.buffer)); } - const memory = new SimpleMemory(); - memory.pvm = pvm; - return memory; + // Apply memory changes + if (newState.memory && newState.memory.length > 0) { + for (const memEntry of newState.memory) { + const address = memEntry.address; + const bytes = new Uint8Array(Buffer.from(memEntry.contents.slice(2), "hex")); + pvm.setMemory(address, bytes); + } + } }; const hostCall = async ({ pvm, hostCallIdentifier, + tracesFile, + currentHostCallIndex, }: { pvm: PvmApiInterface; hostCallIdentifier: number; + tracesFile?: TracesFile | null; + currentHostCallIndex?: number; }): Promise => { - // TODO [ToDr] Introduce host calls handling via JSON trace. - getRegisters(pvm); - getMemory(pvm); - // return { hostCallIdentifier, status: CommandStatus.SUCCESS }; - return { hostCallIdentifier, status: CommandStatus.ERROR, error: new Error("Unknown host call identifier") }; + // If no traces file is provided, return error + if (!tracesFile) { + return { + hostCallIdentifier, + status: CommandStatus.ERROR, + error: new Error("No host calls trace file provided"), + }; + } + + // If no current index is provided, return error + if (currentHostCallIndex === undefined || currentHostCallIndex === null) { + return { + hostCallIdentifier, + status: CommandStatus.ERROR, + error: new Error("No current host call index provided"), + }; + } + + // Check if we have more host calls to process + if (currentHostCallIndex >= tracesFile["host-calls-trace"].length) { + return { + hostCallIdentifier, + status: CommandStatus.ERROR, + error: new Error( + `Host call index ${currentHostCallIndex} exceeds available traces (${tracesFile["host-calls-trace"].length})`, + ), + }; + } + + const currentTraceEntry = tracesFile["host-calls-trace"][currentHostCallIndex]; + + try { + // Validate the "before" state if provided + if (currentTraceEntry.before) { + const validation = await validateVmState(pvm, currentTraceEntry.before); + if (!validation.isValid) { + return { + hostCallIdentifier, + status: CommandStatus.ERROR, + error: new Error(`Pre-condition validation failed:\n${validation.errors.join("\n")}`), + }; + } + } + + // Apply the "after" state changes + await applyVmState(pvm, currentTraceEntry.after); + + return { + hostCallIdentifier, + status: CommandStatus.SUCCESS, + }; + } catch (error) { + return { + hostCallIdentifier, + status: CommandStatus.ERROR, + error: new Error(error instanceof Error ? error.message : "Unknown error during host call processing"), + }; + } }; -export const runHostCall = async ({ pvm, hostCallIdentifier }: HostCallParams): Promise => { +export const runHostCall = async ({ + pvm, + hostCallIdentifier, + tracesFile, + currentHostCallIndex, +}: HostCallParams): Promise => { if (!pvm) { throw new Error("PVM is uninitialized."); } try { - return await hostCall({ pvm, hostCallIdentifier }); + return await hostCall({ + pvm, + hostCallIdentifier, + tracesFile, + currentHostCallIndex, + }); } catch (error) { return { hostCallIdentifier, diff --git a/src/packages/web-worker/pvm.ts b/src/packages/web-worker/pvm.ts index 92449d28..27d490e7 100644 --- a/src/packages/web-worker/pvm.ts +++ b/src/packages/web-worker/pvm.ts @@ -33,26 +33,12 @@ export const initPvm = async (pvm: InternalPvmInstance, program: Uint8Array, ini memoryBuilder.setData(idx, new Uint8Array(memoryChunk.contents)); } - const tryFinalize = (heapStartIndex: number, heapEndIndex: number) => { - try { - return memoryBuilder.finalize(tryAsMemoryIndex(heapStartIndex), tryAsSbrkIndex(heapEndIndex)); - } catch { - return null; - } - }; - // const SPI_HEAP_START = 302117864; // heap start when max input data - const SPI_HEAP_START = 139264; // tmp - - // const SPI_HEAP_END = 4261281792; // heap end when max stack - const SPI_HEAP_END = 4278050816; // tmp - const maybeMemory = tryFinalize(SPI_HEAP_START, SPI_HEAP_END); - const pageSize = 2 ** 12; - const maxAddressFromPageMap = Math.max(...pageMap.map((page) => page.address + page.length)); - const hasMemoryLayout = maxAddressFromPageMap >= 0; - const heapStartIndex = tryAsMemoryIndex(hasMemoryLayout ? maxAddressFromPageMap + pageSize : 0); - const heapEndIndex = tryAsSbrkIndex(2 ** 32 - 2 * 2 ** 16 - 2 ** 24); - const memory = maybeMemory ?? memoryBuilder.finalize(heapStartIndex, heapEndIndex); + const heapStart = Math.max(...pageMap.map((page) => page.address + page.length)); + const hasMemoryLayout = heapStart >= 0; + const heapStartIndex = initialState.heapStart ?? (hasMemoryLayout ? heapStart + pageSize : 0); + const heapEndIndex = initialState.heapEnd ?? 2 ** 32 - 2 * 2 ** 16 - 2 ** 24; + const memory = memoryBuilder.finalize(tryAsMemoryIndex(heapStartIndex), tryAsSbrkIndex(heapEndIndex)); const registers = new Registers(); registers.copyFrom(new BigUint64Array(initialState.regs!.map((x) => BigInt(x)))); pvm.reset(new Uint8Array(program), initialState.pc ?? 0, initialState.gas ?? 0n, registers, memory); diff --git a/src/packages/web-worker/types.ts b/src/packages/web-worker/types.ts index 3e74033e..220a9a21 100644 --- a/src/packages/web-worker/types.ts +++ b/src/packages/web-worker/types.ts @@ -1,4 +1,4 @@ -import { CurrentInstruction, ExpectedState, InitialState } from "@/types/pvm"; +import { CurrentInstruction, ExpectedState, InitialState, TracesFile } from "@/types/pvm"; import { WasmPvmShellInterface } from "./wasmBindgenShell"; import { Pvm as InternalPvm } from "@/types/pvm"; import { SerializedFile } from "@/lib/utils.ts"; @@ -54,7 +54,14 @@ export type CommandWorkerRequestParams = | { command: Commands.RUN } | { command: Commands.STOP } | { command: Commands.MEMORY; payload: { startAddress: number; stopAddress: number } } - | { command: Commands.HOST_CALL; payload: { hostCallIdentifier: number } } + | { + command: Commands.HOST_CALL; + payload: { + hostCallIdentifier: number; + tracesFile?: TracesFile | null; + currentHostCallIndex?: number; + }; + } | { command: Commands.SET_SERVICE_ID; payload: { serviceId: number } } | { command: Commands.UNLOAD }; diff --git a/src/packages/web-worker/worker.ts b/src/packages/web-worker/worker.ts index 62b827e9..f140f426 100644 --- a/src/packages/web-worker/worker.ts +++ b/src/packages/web-worker/worker.ts @@ -107,6 +107,8 @@ async function rawOnMessage(e: MessageEvent) { const data = await commandHandlers.runHostCall({ pvm, hostCallIdentifier: e.data.payload.hostCallIdentifier, + tracesFile: e.data.payload.tracesFile, + currentHostCallIndex: e.data.payload.currentHostCallIndex, }); postTypedMessage({ diff --git a/src/store/debugger/debuggerSlice.ts b/src/store/debugger/debuggerSlice.ts index 23713f9c..fcad0643 100644 --- a/src/store/debugger/debuggerSlice.ts +++ b/src/store/debugger/debuggerSlice.ts @@ -1,5 +1,5 @@ import { createListenerMiddleware, createSlice } from "@reduxjs/toolkit"; -import { AvailablePvms, CurrentInstruction, ExpectedState, Status } from "@/types/pvm.ts"; +import { AvailablePvms, CurrentInstruction, ExpectedState, Status, TracesFile } from "@/types/pvm.ts"; import { InstructionMode } from "@/components/Instructions/types.ts"; import { RootState } from "@/store"; import { SelectedPvmWithPayload } from "@/components/PvmSelect"; @@ -29,7 +29,8 @@ export interface DebuggerState { pvmLoaded: boolean; stepsToPerform: number; serviceId: number | null; - hostCallsTrace: null; + hostCallsTrace: TracesFile | null; + currentHostCallIndex: number; spiArgs: string | null; activeMobileTab: "program" | "status" | "memory"; } @@ -86,6 +87,7 @@ const initialState: DebuggerState = { stepsToPerform: 1, spiArgs: null, hostCallsTrace: null, + currentHostCallIndex: 0, serviceId: parseInt("0x30303030", 16), activeMobileTab: "program", }; @@ -157,6 +159,16 @@ const debuggerSlice = createSlice({ setActiveMobileTab(state, action) { state.activeMobileTab = action.payload; }, + setHostCallsTrace(state, action) { + state.hostCallsTrace = action.payload; + state.currentHostCallIndex = 0; // Reset index when setting new traces + }, + setCurrentHostCallIndex(state, action) { + state.currentHostCallIndex = action.payload; + }, + incrementHostCallIndex(state) { + state.currentHostCallIndex += 1; + }, }, }); @@ -181,6 +193,9 @@ export const { setPvmOptions, setSelectedPvms, setActiveMobileTab, + setHostCallsTrace, + setCurrentHostCallIndex, + incrementHostCallIndex, } = debuggerSlice.actions; export const debuggerSliceListenerMiddleware = createListenerMiddleware(); @@ -225,5 +240,7 @@ export const selectIsDebugFinished = (state: RootState) => state.debugger.isDebu export const selectPvmInitialized = (state: RootState) => state.debugger.pvmInitialized; export const selectAllAvailablePvms = (state: RootState) => state.debugger.pvmOptions.allAvailablePvms; export const selectSelectedPvms = (state: RootState) => state.debugger.pvmOptions.selectedPvm; +export const selectHostCallsTrace = (state: RootState) => state.debugger.hostCallsTrace; +export const selectCurrentHostCallIndex = (state: RootState) => state.debugger.currentHostCallIndex; export default debuggerSlice.reducer; diff --git a/src/store/workers/workersSlice.ts b/src/store/workers/workersSlice.ts index 50111603..88932dc3 100644 --- a/src/store/workers/workersSlice.ts +++ b/src/store/workers/workersSlice.ts @@ -6,6 +6,7 @@ import { setIsDebugFinished, setIsRunMode, setIsStepMode, + incrementHostCallIndex, } from "@/store/debugger/debuggerSlice.ts"; import PvmWorker from "@/packages/web-worker/worker?worker&inline"; import { virtualTrapInstruction } from "@/utils/virtualTrapInstruction.ts"; @@ -280,7 +281,11 @@ export const handleHostCall = createAsyncThunk( .map(async (worker) => { const resp = await asyncWorkerPostMessage(worker.id, worker.worker, { command: Commands.HOST_CALL, - payload: { hostCallIdentifier: worker.exitArg ?? -1 }, + payload: { + hostCallIdentifier: worker.exitArg ?? -1, + tracesFile: state.debugger.hostCallsTrace, + currentHostCallIndex: state.debugger.currentHostCallIndex, + }, }); // TODO [ToDr] Handle host call response? @@ -295,6 +300,9 @@ export const handleHostCall = createAsyncThunk( }), ); + // Increment host call index after successful processing + dispatch(incrementHostCallIndex()); + if (selectShouldContinueRunning(state)) { dispatch(continueAllWorkers()); } diff --git a/src/types/pvm.ts b/src/types/pvm.ts index 8f214c42..8b2f1d1a 100644 --- a/src/types/pvm.ts +++ b/src/types/pvm.ts @@ -12,6 +12,8 @@ export type InitialState = { pageMap?: PageMapItem[]; memory?: MemoryChunkItem[]; gas?: bigint; + heapStart?: number; + heapEnd?: number; }; export type MemoryChunkItem = { @@ -72,3 +74,32 @@ export enum AvailablePvms { WASM_FILE = "wasm-file", WASM_WS = "wasm-websocket", } + +// Host calls trace types based on JSON schema +export type TraceMemoryEntry = { + address: number; + contents: string; // hex string like "0x04" +}; + +export type TraceVmState = { + gas?: string; // hex string like "0x1234" + regs?: Record; // register index -> hex value + memory: TraceMemoryEntry[]; +}; + +export type HostCallTraceEntry = { + ecalli?: number; + pc?: number; + before?: TraceVmState; + after: TraceVmState; +}; + +export type TracesFile = { + "initial-pc": number; + "initial-gas": string; // hex string + "initial-args": string; // hex string + "expected-gas"?: string; // hex string + "expected-status"?: "panic" | "halt" | "page-fault"; + "spi-program"?: string; // hex string + "host-calls-trace": HostCallTraceEntry[]; +}; diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts index 776e0cb8..b3cbc39e 100644 --- a/src/types/type-guards.ts +++ b/src/types/type-guards.ts @@ -1,5 +1,6 @@ import { Args, ArgumentType } from "@typeberry/pvm-debugger-adapter"; -import { CurrentInstruction } from "./pvm"; +import { CurrentInstruction, TracesFile, HostCallTraceEntry, TraceVmState, TraceMemoryEntry } from "./pvm"; +import { z } from "zod"; export function isInstructionError( instruction: CurrentInstruction, @@ -10,3 +11,65 @@ export function isInstructionError( export function isOneImmediateArgs(args: Args): args is Extract { return args.type === ArgumentType.ONE_IMMEDIATE; } + +// Zod schemas for trace validation +const hexStringSchema = z.string().regex(/^0x[0-9a-fA-F]+$/, "Must be a valid hex string"); + +const traceMemoryEntrySchema = z.object({ + address: z.number().int().min(0), + contents: hexStringSchema, +}); + +const traceVmStateSchema = z.object({ + gas: hexStringSchema.optional(), + regs: z.record(z.string().regex(/^[0-9]+$/), hexStringSchema).optional(), + memory: z.array(traceMemoryEntrySchema), +}); + +const hostCallTraceEntrySchema = z.object({ + ecalli: z.number().int().optional(), + pc: z.number().int().optional(), + before: traceVmStateSchema.optional(), + after: traceVmStateSchema, +}); + +const tracesFileSchema = z.object({ + "initial-pc": z.number().int(), + "initial-gas": hexStringSchema, + "initial-args": hexStringSchema, + "expected-gas": hexStringSchema.optional(), + "expected-status": z.enum(["panic", "halt", "page-fault"]).optional(), + "spi-program": hexStringSchema.optional(), + "host-calls-trace": z.array(hostCallTraceEntrySchema), +}); + +// Export schemas for direct use +export { traceMemoryEntrySchema, traceVmStateSchema, hostCallTraceEntrySchema, tracesFileSchema }; + +// Validation functions using Zod schemas +export function isTraceMemoryEntry(value: unknown): value is TraceMemoryEntry { + return traceMemoryEntrySchema.safeParse(value).success; +} + +export function isTraceVmState(value: unknown): value is TraceVmState { + return traceVmStateSchema.safeParse(value).success; +} + +export function isHostCallTraceEntry(value: unknown): value is HostCallTraceEntry { + return hostCallTraceEntrySchema.safeParse(value).success; +} + +export function isTracesFile(value: unknown): value is TracesFile { + return tracesFileSchema.safeParse(value).success; +} + +// Validation function that returns detailed error information +export function validateTracesFile( + value: unknown, +): { success: true; data: TracesFile } | { success: false; error: z.ZodError } { + const result = tracesFileSchema.safeParse(value); + if (result.success) { + return { success: true, data: result.data }; + } + return { success: false, error: result.error }; +}