[CLI] fix stylus abi exports (#8726)

This commit is contained in:
Firekeeper
2026-03-26 18:58:18 +07:00
committed by GitHub
parent fdd8c17813
commit 3a18c6e6a8
3 changed files with 124 additions and 68 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---
[CLI] fix stylus abi exports
@@ -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";
@@ -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);
@@ -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);
@@ -99,9 +88,9 @@ 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) {
@@ -109,15 +98,21 @@ async function buildStylus(spinner: Ora, secretKey?: string) {
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",
});
@@ -127,23 +122,16 @@ 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 selectedIndex = 0;
let selectedContractName: string | undefined;
let selectedAbiContent: string | undefined;
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:",
@@ -151,46 +139,31 @@ async function buildStylus(spinner: Ora, secretKey?: string) {
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.");
const selectedInterface = interfaces[selectedIndex];
if (!selectedInterface) {
spinner.fail("No interface found.");
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);
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 = {
@@ -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;
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) {
@@ -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"]);