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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-laws-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

[CLI] fix stylus abi exports
181 changes: 119 additions & 62 deletions packages/thirdweb/src/cli/commands/stylus/builder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parseAbiItem } from "abitype";
import open from "open";
import ora, { type Ora } from "ora";
import prompts from "prompts";
Expand All @@ -16,12 +17,6 @@ export async function publishStylus(secretKey?: string) {

checkPrerequisites(spinner, "cargo", ["--version"], "Rust (cargo)");
checkPrerequisites(spinner, "rustc", ["--version"], "Rust compiler (rustc)");
checkPrerequisites(
spinner,
"solc",
["--version"],
"Solidity compiler (solc)",
);

const uri = await buildStylus(spinner, secretKey);

Expand All @@ -35,12 +30,6 @@ export async function deployStylus(secretKey?: string) {

checkPrerequisites(spinner, "cargo", ["--version"], "Rust (cargo)");
checkPrerequisites(spinner, "rustc", ["--version"], "Rust compiler (rustc)");
checkPrerequisites(
spinner,
"solc",
["--version"],
"Solidity compiler (solc)",
);

const uri = await buildStylus(spinner, secretKey);

Expand Down Expand Up @@ -99,25 +88,31 @@ async function buildStylus(spinner: Ora, secretKey?: string) {
}
spinner.succeed("Initcode generated.");

// Step 3: Run stylus command to generate abi
// Step 3: Run stylus command to generate abi (plain Solidity, no solc needed)
spinner.start("Generating ABI...");
const abiResult = spawnSync("cargo", ["stylus", "export-abi", "--json"], {
const abiResult = spawnSync("cargo", ["stylus", "export-abi"], {
encoding: "utf-8",
});
if (abiResult.status !== 0) {
spinner.fail("Failed to generate ABI.");
process.exit(1);
}

const abiContent = abiResult.stdout.trim();
if (!abiContent) {
const solidityOutput = abiResult.stdout.trim();
if (!solidityOutput) {
spinner.fail("Failed to generate ABI.");
process.exit(1);
}

const interfaces = parseSolidityInterfaces(solidityOutput);
if (interfaces.length === 0) {
spinner.fail("No interfaces found in ABI output.");
process.exit(1);
}
spinner.succeed("ABI generated.");

// Step 3.5: detect the constructor
spinner.start("Detecting constructor");
spinner.start("Detecting constructor\u2026");
const constructorResult = spawnSync("cargo", ["stylus", "constructor"], {
encoding: "utf-8",
});
Expand All @@ -127,70 +122,48 @@ async function buildStylus(spinner: Ora, secretKey?: string) {
process.exit(1);
}

const constructorSigRaw = constructorResult.stdout.trim(); // e.g. "constructor(address owner)"
const constructorSigRaw = constructorResult.stdout.trim();
spinner.succeed(`Constructor found: ${constructorSigRaw || "none"}`);

// Step 4: Process the output
const parts = abiContent.split(/======= <stdin>:/g).filter(Boolean);
const contractNames = extractContractNamesFromExportAbi(abiContent);

let selectedContractName: string | undefined;
let selectedAbiContent: string | undefined;
let selectedIndex = 0;

if (contractNames.length === 1) {
selectedContractName = contractNames[0]?.replace(/^I/, "");
selectedAbiContent = parts[0];
} else {
if (interfaces.length > 1) {
const response = await prompts({
choices: contractNames.map((name, idx) => ({
title: name,
choices: interfaces.map((iface, idx) => ({
title: iface.name,
value: idx,
})),
message: "Select entrypoint:",
name: "contract",
type: "select",
});

const selectedIndex = response.contract;

if (typeof selectedIndex !== "number") {
if (typeof response.contract !== "number") {
spinner.fail("No contract selected.");
process.exit(1);
}

selectedContractName = contractNames[selectedIndex]?.replace(/^I/, "");
selectedAbiContent = parts[selectedIndex];
selectedIndex = response.contract;
}

if (!selectedAbiContent) {
throw new Error("Entrypoint not found");
}

if (!selectedContractName) {
spinner.fail("Error: Could not determine contract name from ABI output.");
process.exit(1);
}

let cleanedAbi = "";
try {
const jsonMatch = selectedAbiContent.match(/\[.*\]/s);
if (jsonMatch) {
cleanedAbi = jsonMatch[0];
} else {
throw new Error("No valid JSON ABI found in the file.");
}
} catch (error) {
spinner.fail("Error: ABI file contains invalid format.");
console.error(error);
const selectedInterface = interfaces[selectedIndex];
if (!selectedInterface) {
spinner.fail("No interface found.");
process.exit(1);
}

// biome-ignore lint/suspicious/noExplicitAny: <>
const abiArray: any[] = JSON.parse(cleanedAbi);
const selectedContractName = selectedInterface.name.replace(/^I/, "");
// biome-ignore lint/suspicious/noExplicitAny: ABI is untyped JSON from parseAbiItem
const abiArray: any[] = selectedInterface.abi;

const constructorAbi = constructorSigToAbi(constructorSigRaw);
if (constructorAbi && !abiArray.some((e) => e.type === "constructor")) {
abiArray.unshift(constructorAbi); // put it at the top for readability
if (
constructorAbi &&
// biome-ignore lint/suspicious/noExplicitAny: ABI entries have varying shapes
!abiArray.some((e: any) => e.type === "constructor")
) {
abiArray.unshift(constructorAbi);
}

const metadata = {
Expand Down Expand Up @@ -256,10 +229,94 @@ async function buildStylus(spinner: Ora, secretKey?: string) {
}
}

function extractContractNamesFromExportAbi(abiRawOutput: string): string[] {
return [...abiRawOutput.matchAll(/<stdin>:(I?[A-Za-z0-9_]+)/g)]
.map((m) => m[1])
.filter((name): name is string => typeof name === "string");
// biome-ignore lint/suspicious/noExplicitAny: ABI items from parseAbiItem are untyped
type AbiEntry = any;
type ParsedInterface = { name: string; abi: AbiEntry[] };

function parseSolidityInterfaces(source: string): ParsedInterface[] {
const results: ParsedInterface[] = [];

const ifaceRegex = /interface\s+(I?[A-Za-z0-9_]+)\s*\{([\s\S]*?)\n\}/g;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface regex requires a newline before the closing brace (\n}), which will fail to match interfaces that don't have this specific formatting. For example, interface IFoo { function bar(); } or interfaces with spaces/tabs before the closing brace will not be detected.

Impact: Valid interfaces will be silently skipped, potentially resulting in zero interfaces found and process exit with error "No interfaces found in ABI output."

Fix: Remove the newline requirement from the regex:

const ifaceRegex = /interface\s+(I?[A-Za-z0-9_]+)\s*\{([\s\S]*?)\}/g;
Suggested change
const ifaceRegex = /interface\s+(I?[A-Za-z0-9_]+)\s*\{([\s\S]*?)\n\}/g;
const ifaceRegex = /interface\s+(I?[A-Za-z0-9_]+)\s*\{([\s\S]*?)\}/g;

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

for (
let ifaceMatch = ifaceRegex.exec(source);
ifaceMatch !== null;
ifaceMatch = ifaceRegex.exec(source)
) {
const name = ifaceMatch[1] ?? "";
const body = ifaceMatch[2] ?? "";
const abi: AbiEntry[] = [];

// Build struct lookup: name -> tuple type string
const structs = new Map<string, string>();
const structRegex = /struct\s+(\w+)\s*\{([^}]*)\}/g;
for (
let structMatch = structRegex.exec(body);
structMatch !== null;
structMatch = structRegex.exec(body)
) {
const fields = (structMatch[2] ?? "")
.split(";")
.map((f) => f.trim())
.filter(Boolean)
.map((f) => f.split(/\s+/)[0] ?? "");
structs.set(structMatch[1] ?? "", `(${fields.join(",")})`);
}

// Resolve struct references in a type string (iterative for nested structs)
const resolveStructs = (sig: string): string => {
let resolved = sig;
for (let i = 0; i < 10; i++) {
let changed = false;
for (const [sName, sTuple] of structs) {
const re = new RegExp(`\\b${sName}\\b(\\[\\])?`, "g");
const next = resolved.replace(
re,
(_, arr) => `${sTuple}${arr ?? ""}`,
);
if (next !== resolved) {
resolved = next;
changed = true;
}
}
if (!changed) break;
}
return resolved;
};

// Extract each statement (function/error/event) delimited by ;
const statements = body
.split(";")
.map((s) => s.replace(/\n/g, " ").trim())
.filter(
(s) =>
s.startsWith("function ") ||
s.startsWith("error ") ||
s.startsWith("event "),
);

for (const stmt of statements) {
// Strip Solidity qualifiers that abitype doesn't expect
let cleaned = stmt
.replace(/\b(external|public|internal|private)\b/g, "")
.replace(/\b(memory|calldata|storage)\b/g, "")
.replace(/\s+/g, " ")
.trim();

// Resolve struct type names to tuple types
cleaned = resolveStructs(cleaned);

try {
const parsed = parseAbiItem(cleaned);
abi.push(parsed);
} catch {
// Skip unparseable items
}
}

results.push({ abi, name });
}

return results;
}

function getUrl(hash: string, command: string) {
Expand Down
6 changes: 0 additions & 6 deletions packages/thirdweb/src/cli/commands/stylus/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ export async function createStylusProject() {

checkPrerequisites(spinner, "cargo", ["--version"], "Rust (cargo)");
checkPrerequisites(spinner, "rustc", ["--version"], "Rust compiler (rustc)");
checkPrerequisites(
spinner,
"solc",
["--version"],
"Solidity compiler (solc)",
);

// Step 1: Ensure cargo is installed
const cargoCheck = spawnSync("cargo", ["--version"]);
Expand Down
Loading