Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Install SolDB CLI
run: cargo install --git https://github.com/walnuthq/soldb.git --branch main --bin soldb --locked soldb-cli

- name: Check SolDB CLI commands
run: |
soldb --help
soldb trace --help
soldb simulate --help
soldb list-events --help

- name: Lint code
run: npm run lint

Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,17 @@ Docker images and deployment guides will be available soon. In the meantime, ple
This project depends on [SolDB](https://github.com/walnuthq/soldb). Install it using:

```bash
pip install git+https://github.com/walnuthq/soldb.git
cargo install --git https://github.com/walnuthq/soldb.git --branch main --bin soldb --locked soldb-cli
```

> **Note:** SolDB is currently in beta and doesn't have a PyPI package yet.
For local development with a sibling SolDB checkout:

```bash
cd ../soldb
cargo build -p soldb-cli
cd ../walnut
echo 'SOLDB_BIN="../soldb/target/debug/soldb"' >> .env.local
```

---

Expand All @@ -98,6 +105,7 @@ Edit `.env.local` and set the following variables for your EVM network:
NEXT_PUBLIC_RPC_URL="RPC_URL_WITH_DEBUG_ENDPOINTS_SUPPORT"
NEXT_PUBLIC_NETWORK_NAME="OP Sepolia"
NEXT_PUBLIC_CHAIN_ID="11155420"
SOLDB_BIN="soldb"
```

> Your node RPC URL **must** support `debug_traceTransaction` and `debug_traceCall` endpoints. These are usually not available on public nodes, so use a dedicated node RPC URL.
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"test": "echo \"No tests specified\" && exit 0",
"test": "node --test test/*.test.js",
"check": "npm run type-check && npm run lint",
"db:generate": "dotenv -e .env.local -- drizzle-kit generate",
"db:push": "dotenv -e .env.local -- drizzle-kit push",
Expand Down Expand Up @@ -85,6 +85,7 @@
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.35.2",
"jiti": "^1.21.7",
"postcss": "^8.5.10",
"postcss-import": "^15.1.0",
"tailwindcss": "^3.4.18",
Expand Down
100 changes: 76 additions & 24 deletions src/app/api/v1/debug-transaction/convert-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,50 @@ function parsePcToSourceMapping(
};
}

const debugCallResponseToTransactionSimulationResult = ({
traceCall,
const normalizeAddressKey = (address: string) => address.toLowerCase();

function buildDebuggerSources(contract: DebugCallContract, sourcifyContract?: Contract) {
const fileIndexToPath: Record<number, string> = {};
const sourceCode: Record<string, string> = {};
const sources: Record<number, string> = {};

const addSource = (sourceId: number, sourcePath: string, content: string) => {
if (!Number.isFinite(sourceId) || !sourcePath || !content) return;
fileIndexToPath[sourceId] = sourcePath;
sourceCode[sourcePath] = content;
sources[sourceId] = content;
};

const soldbSources = contract.sources ?? {};
if (contract.debugAvailable !== false && Object.keys(soldbSources).length > 0) {
for (const [sourceIdText, content] of Object.entries(soldbSources)) {
if (typeof content !== 'string') continue;
const sourceId = Number(sourceIdText);
const sourcePath = contract.sourcePaths?.[sourceId] ?? `source-${sourceId}.sol`;
addSource(sourceId, sourcePath, content);
}
}

if (Object.keys(sources).length === 0) {
const contractSources = sourcifyContract?.sources || [];
contractSources.forEach((source, idx) => {
if (!source.path || !source.content) return;
addSource(Number(idx), source.path, source.content);
});
}

return { fileIndexToPath, sourceCode, sources };
}

export const buildDebuggerInfo = ({
steps,
contracts,
sourcifyContracts,
contractCallsMap,
functionCallsMap,
txHash
functionCallsMap
}: {
traceCall: WalnutTraceCall;
steps: Step[];
contracts: Record<Address, DebugCallContract>;
txHash: string;
sourcifyContracts: Contract[];
contractCallsMap: Record<string, ContractCall>;
functionCallsMap: Record<string, FunctionCall>;
Expand All @@ -81,19 +112,14 @@ const debugCallResponseToTransactionSimulationResult = ({
// Add null check for contracts
if (contracts && typeof contracts === 'object') {
for (const [address, contract] of Object.entries(contracts)) {
// 1. Find sourcifyContract for this address;
const sourcifyContract = sourcifyContracts.find((c) => c.address === address);
const contractSources = sourcifyContract?.sources || [];
const fileIndexToPath: Record<number, string> = {};
const sourceCode: Record<string, string> = {};
const sources: Record<number, string> = {};
contractSources.forEach((source, idx) => {
if (!source.path || !source.content) return; // skip if missing path or content
const cleanPath = source.path;
fileIndexToPath[Number(idx)] = cleanPath;
sourceCode[cleanPath] = source.content;
sources[Number(idx)] = source.content;
});
const normalizedAddress = normalizeAddressKey(address);
const sourcifyContract = sourcifyContracts.find(
(c) => normalizeAddressKey(c.address) === normalizedAddress
);
const { fileIndexToPath, sourceCode, sources } = buildDebuggerSources(
contract,
sourcifyContract
);
// Optimized filtering: group PCs by mapping to avoid duplicates
const pcToCodeInfo: Record<number, { codeLocations: any[] }> = {};
const mappingToPcs: Record<string, number[]> = {};
Expand Down Expand Up @@ -130,7 +156,7 @@ const debugCallResponseToTransactionSimulationResult = ({
};
}
}
contractDebuggerData[address] = {
contractDebuggerData[normalizedAddress] = {
pcToCodeInfo,
sourceCode
};
Expand All @@ -141,7 +167,7 @@ const debugCallResponseToTransactionSimulationResult = ({
const debuggerTrace: any[] = [];
// Process all steps in execution order (not by call hierarchy)
steps.forEach((step, stepIndex) => {
const traceCallIndex = step.traceCallIndex;
const traceCallIndex = step.traceCallIndex ?? 0;
const contractCall = contractCallsMap[traceCallIndex];
const functionCall = functionCallsMap[traceCallIndex];

Expand Down Expand Up @@ -191,12 +217,15 @@ const debugCallResponseToTransactionSimulationResult = ({
contractAddress = parentContractCall?.entryPoint?.storageAddress || null;
}

const classData = contractAddress
? (contractDebuggerData[contractAddress] ??
contractDebuggerData[normalizeAddressKey(contractAddress)])
: undefined;

// Skip if no contract address or no debugger data for this contract
if (!contractAddress || !contractDebuggerData[contractAddress]) {
if (!contractAddress || !classData) {
return;
}

const classData = contractDebuggerData[contractAddress];
const pcInfo = classData.pcToCodeInfo[step.pc];

// Skip if no PC mapping for this step
Expand Down Expand Up @@ -241,4 +270,27 @@ const debugCallResponseToTransactionSimulationResult = ({
} as DebuggerInfo;
};

const debugCallResponseToTransactionSimulationResult = ({
steps,
contracts,
sourcifyContracts,
contractCallsMap,
functionCallsMap
}: {
traceCall: WalnutTraceCall;
steps: Step[];
contracts: Record<Address, DebugCallContract>;
txHash: string;
sourcifyContracts: Contract[];
contractCallsMap: Record<string, ContractCall>;
functionCallsMap: Record<string, FunctionCall>;
}) =>
buildDebuggerInfo({
steps,
contracts,
sourcifyContracts,
contractCallsMap,
functionCallsMap
});

export default debugCallResponseToTransactionSimulationResult;
68 changes: 10 additions & 58 deletions src/app/api/v1/events/[tx_hash]/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/auth-server';
import { exec } from 'child_process';
import { promisify } from 'util';
import { ContractCallEvent, DecodedItem } from '@/lib/simulation/types';
import { processTransactionRequest } from '@/app/api/v1/utils/transaction-processing';
import { getSupportedNetworks } from '@/lib/get-supported-networks';
import { getRpcUrlForChainOptimized } from '@/lib/public-network-utils';

const execAsync = promisify(exec);
import { soldbListEvents } from '@/app/api/v1/soldb';

interface SoldbEventData {
index: number;
Expand Down Expand Up @@ -134,40 +131,14 @@ export const GET = async (
}
}

// Build soldb list-events command with ethdebug-dir parameters
let command = `soldb list-events ${tx_hash} --json-events`;

// Add ethdebug-dir parameters if available
if (ethdebugDirs && ethdebugDirs.length > 0) {
ethdebugDirs.forEach((dir) => {
command += ` --ethdebug-dir ${dir}`;
});
}

// Add RPC URL if available
if (rpcUrl) {
command += ` --rpc ${rpcUrl}`;
}

// Log the command being executed (similar to simulate and trace)
console.log('Executing soldb command:', command);
if (cwd) {
console.log('Working directory:', cwd);
}

try {
const { stdout, stderr } = await execAsync(command, {
timeout: 30000, // 30 second timeout
cwd: cwd || process.cwd()
const soldbResponse: SoldbEventsResponse = await soldbListEvents({
txHash: tx_hash,
rpcUrl,
ethdebugDirs,
cwd
});

if (stderr) {
console.error('soldb stderr:', stderr);
}

// Parse the JSON output
const soldbResponse: SoldbEventsResponse = JSON.parse(stdout);

// Transform soldb events to ContractCallEvent format
const contractCallEvents: ContractCallEvent[] = soldbResponse.events.map((event, index) =>
transformSoldbEventToContractCallEvent(event, index)
Expand All @@ -182,32 +153,13 @@ export const GET = async (
} catch (execError: any) {
console.error('soldb execution error:', execError);

// Handle specific error cases
if (execError.code === 'ENOENT') {
return NextResponse.json(
{
error:
'soldb command not found. Please ensure soldb is installed and available in PATH.'
},
{ status: 500 }
);
}

// Try to parse error message from stderr
let errorMessage = 'Failed to fetch events';
if (execError.stderr) {
// Look for the "soldb: error:" part and everything after it
const errorMatch = execError.stderr.match(/(soldb: error: .+)/);
if (errorMatch) {
errorMessage = errorMatch[1];
}
}

return NextResponse.json(
{
error: errorMessage
error: execError.userMessage || execError.message || 'Failed to fetch events',
...(execError.details && { details: execError.details }),
...(execError.code && { code: execError.code })
},
{ status: 500 }
{ status: execError.statusCode || 500 }
);
}
} catch (error: any) {
Expand Down
Loading
Loading