Skip to content

Commit 3a18c6e

Browse files
authored
[CLI] fix stylus abi exports (#8726)
1 parent fdd8c17 commit 3a18c6e

3 files changed

Lines changed: 124 additions & 68 deletions

File tree

.changeset/poor-laws-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
[CLI] fix stylus abi exports

packages/thirdweb/src/cli/commands/stylus/builder.ts

Lines changed: 119 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawnSync } from "node:child_process";
22
import { existsSync, readFileSync } from "node:fs";
33
import { join } from "node:path";
4+
import { parseAbiItem } from "abitype";
45
import open from "open";
56
import ora, { type Ora } from "ora";
67
import prompts from "prompts";
@@ -16,12 +17,6 @@ export async function publishStylus(secretKey?: string) {
1617

1718
checkPrerequisites(spinner, "cargo", ["--version"], "Rust (cargo)");
1819
checkPrerequisites(spinner, "rustc", ["--version"], "Rust compiler (rustc)");
19-
checkPrerequisites(
20-
spinner,
21-
"solc",
22-
["--version"],
23-
"Solidity compiler (solc)",
24-
);
2520

2621
const uri = await buildStylus(spinner, secretKey);
2722

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

3631
checkPrerequisites(spinner, "cargo", ["--version"], "Rust (cargo)");
3732
checkPrerequisites(spinner, "rustc", ["--version"], "Rust compiler (rustc)");
38-
checkPrerequisites(
39-
spinner,
40-
"solc",
41-
["--version"],
42-
"Solidity compiler (solc)",
43-
);
4433

4534
const uri = await buildStylus(spinner, secretKey);
4635

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

102-
// Step 3: Run stylus command to generate abi
91+
// Step 3: Run stylus command to generate abi (plain Solidity, no solc needed)
10392
spinner.start("Generating ABI...");
104-
const abiResult = spawnSync("cargo", ["stylus", "export-abi", "--json"], {
93+
const abiResult = spawnSync("cargo", ["stylus", "export-abi"], {
10594
encoding: "utf-8",
10695
});
10796
if (abiResult.status !== 0) {
10897
spinner.fail("Failed to generate ABI.");
10998
process.exit(1);
11099
}
111100

112-
const abiContent = abiResult.stdout.trim();
113-
if (!abiContent) {
101+
const solidityOutput = abiResult.stdout.trim();
102+
if (!solidityOutput) {
114103
spinner.fail("Failed to generate ABI.");
115104
process.exit(1);
116105
}
106+
107+
const interfaces = parseSolidityInterfaces(solidityOutput);
108+
if (interfaces.length === 0) {
109+
spinner.fail("No interfaces found in ABI output.");
110+
process.exit(1);
111+
}
117112
spinner.succeed("ABI generated.");
118113

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

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

133128
// Step 4: Process the output
134-
const parts = abiContent.split(/======= <stdin>:/g).filter(Boolean);
135-
const contractNames = extractContractNamesFromExportAbi(abiContent);
136-
137-
let selectedContractName: string | undefined;
138-
let selectedAbiContent: string | undefined;
129+
let selectedIndex = 0;
139130

140-
if (contractNames.length === 1) {
141-
selectedContractName = contractNames[0]?.replace(/^I/, "");
142-
selectedAbiContent = parts[0];
143-
} else {
131+
if (interfaces.length > 1) {
144132
const response = await prompts({
145-
choices: contractNames.map((name, idx) => ({
146-
title: name,
133+
choices: interfaces.map((iface, idx) => ({
134+
title: iface.name,
147135
value: idx,
148136
})),
149137
message: "Select entrypoint:",
150138
name: "contract",
151139
type: "select",
152140
});
153141

154-
const selectedIndex = response.contract;
155-
156-
if (typeof selectedIndex !== "number") {
142+
if (typeof response.contract !== "number") {
157143
spinner.fail("No contract selected.");
158144
process.exit(1);
159145
}
160146

161-
selectedContractName = contractNames[selectedIndex]?.replace(/^I/, "");
162-
selectedAbiContent = parts[selectedIndex];
147+
selectedIndex = response.contract;
163148
}
164149

165-
if (!selectedAbiContent) {
166-
throw new Error("Entrypoint not found");
167-
}
168-
169-
if (!selectedContractName) {
170-
spinner.fail("Error: Could not determine contract name from ABI output.");
171-
process.exit(1);
172-
}
173-
174-
let cleanedAbi = "";
175-
try {
176-
const jsonMatch = selectedAbiContent.match(/\[.*\]/s);
177-
if (jsonMatch) {
178-
cleanedAbi = jsonMatch[0];
179-
} else {
180-
throw new Error("No valid JSON ABI found in the file.");
181-
}
182-
} catch (error) {
183-
spinner.fail("Error: ABI file contains invalid format.");
184-
console.error(error);
150+
const selectedInterface = interfaces[selectedIndex];
151+
if (!selectedInterface) {
152+
spinner.fail("No interface found.");
185153
process.exit(1);
186154
}
187155

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

191160
const constructorAbi = constructorSigToAbi(constructorSigRaw);
192-
if (constructorAbi && !abiArray.some((e) => e.type === "constructor")) {
193-
abiArray.unshift(constructorAbi); // put it at the top for readability
161+
if (
162+
constructorAbi &&
163+
// biome-ignore lint/suspicious/noExplicitAny: ABI entries have varying shapes
164+
!abiArray.some((e: any) => e.type === "constructor")
165+
) {
166+
abiArray.unshift(constructorAbi);
194167
}
195168

196169
const metadata = {
@@ -256,10 +229,94 @@ async function buildStylus(spinner: Ora, secretKey?: string) {
256229
}
257230
}
258231

259-
function extractContractNamesFromExportAbi(abiRawOutput: string): string[] {
260-
return [...abiRawOutput.matchAll(/<stdin>:(I?[A-Za-z0-9_]+)/g)]
261-
.map((m) => m[1])
262-
.filter((name): name is string => typeof name === "string");
232+
// biome-ignore lint/suspicious/noExplicitAny: ABI items from parseAbiItem are untyped
233+
type AbiEntry = any;
234+
type ParsedInterface = { name: string; abi: AbiEntry[] };
235+
236+
function parseSolidityInterfaces(source: string): ParsedInterface[] {
237+
const results: ParsedInterface[] = [];
238+
239+
const ifaceRegex = /interface\s+(I?[A-Za-z0-9_]+)\s*\{([\s\S]*?)\n\}/g;
240+
for (
241+
let ifaceMatch = ifaceRegex.exec(source);
242+
ifaceMatch !== null;
243+
ifaceMatch = ifaceRegex.exec(source)
244+
) {
245+
const name = ifaceMatch[1] ?? "";
246+
const body = ifaceMatch[2] ?? "";
247+
const abi: AbiEntry[] = [];
248+
249+
// Build struct lookup: name -> tuple type string
250+
const structs = new Map<string, string>();
251+
const structRegex = /struct\s+(\w+)\s*\{([^}]*)\}/g;
252+
for (
253+
let structMatch = structRegex.exec(body);
254+
structMatch !== null;
255+
structMatch = structRegex.exec(body)
256+
) {
257+
const fields = (structMatch[2] ?? "")
258+
.split(";")
259+
.map((f) => f.trim())
260+
.filter(Boolean)
261+
.map((f) => f.split(/\s+/)[0] ?? "");
262+
structs.set(structMatch[1] ?? "", `(${fields.join(",")})`);
263+
}
264+
265+
// Resolve struct references in a type string (iterative for nested structs)
266+
const resolveStructs = (sig: string): string => {
267+
let resolved = sig;
268+
for (let i = 0; i < 10; i++) {
269+
let changed = false;
270+
for (const [sName, sTuple] of structs) {
271+
const re = new RegExp(`\\b${sName}\\b(\\[\\])?`, "g");
272+
const next = resolved.replace(
273+
re,
274+
(_, arr) => `${sTuple}${arr ?? ""}`,
275+
);
276+
if (next !== resolved) {
277+
resolved = next;
278+
changed = true;
279+
}
280+
}
281+
if (!changed) break;
282+
}
283+
return resolved;
284+
};
285+
286+
// Extract each statement (function/error/event) delimited by ;
287+
const statements = body
288+
.split(";")
289+
.map((s) => s.replace(/\n/g, " ").trim())
290+
.filter(
291+
(s) =>
292+
s.startsWith("function ") ||
293+
s.startsWith("error ") ||
294+
s.startsWith("event "),
295+
);
296+
297+
for (const stmt of statements) {
298+
// Strip Solidity qualifiers that abitype doesn't expect
299+
let cleaned = stmt
300+
.replace(/\b(external|public|internal|private)\b/g, "")
301+
.replace(/\b(memory|calldata|storage)\b/g, "")
302+
.replace(/\s+/g, " ")
303+
.trim();
304+
305+
// Resolve struct type names to tuple types
306+
cleaned = resolveStructs(cleaned);
307+
308+
try {
309+
const parsed = parseAbiItem(cleaned);
310+
abi.push(parsed);
311+
} catch {
312+
// Skip unparseable items
313+
}
314+
}
315+
316+
results.push({ abi, name });
317+
}
318+
319+
return results;
263320
}
264321

265322
function getUrl(hash: string, command: string) {

packages/thirdweb/src/cli/commands/stylus/create.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@ export async function createStylusProject() {
88

99
checkPrerequisites(spinner, "cargo", ["--version"], "Rust (cargo)");
1010
checkPrerequisites(spinner, "rustc", ["--version"], "Rust compiler (rustc)");
11-
checkPrerequisites(
12-
spinner,
13-
"solc",
14-
["--version"],
15-
"Solidity compiler (solc)",
16-
);
1711

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

0 commit comments

Comments
 (0)