1use anyhow::{anyhow, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ContractAbi {
7 pub contract_id: String,
8 pub contract_name: String,
9 pub functions: Vec<AbiFunction>,
10 pub variables: Vec<AbiVariable>,
11 pub maps: Vec<AbiMap>,
12 pub fungible_tokens: Vec<String>,
13 pub non_fungible_tokens: Vec<AbiNft>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AbiFunction {
18 pub name: String,
19 pub access: FunctionAccess,
20 pub args: Vec<AbiArg>,
21 pub outputs: AbiType,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "snake_case")]
26pub enum FunctionAccess {
27 Public,
28 ReadOnly,
29 Private,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AbiArg {
34 pub name: String,
35 pub r#type: AbiType,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum AbiType {
41 Simple(String),
42 StringAscii {
44 #[serde(rename = "string-ascii")]
45 string_ascii: StringLen,
46 },
47 StringUtf8 {
49 #[serde(rename = "string-utf8")]
50 string_utf8: StringLen,
51 },
52 Buffer {
54 buffer: StringLen,
55 },
56 Buff {
58 buff: u32,
59 },
60 List {
61 list: ListDef,
62 },
63 Tuple {
64 tuple: Vec<TupleEntry>,
65 },
66 Optional {
67 optional: Box<AbiType>,
68 },
69 Response {
70 response: ResponseDef,
71 },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct StringLen {
76 pub length: u32,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ListDef {
81 pub r#type: Box<AbiType>,
82 pub length: u32,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct TupleEntry {
87 pub name: String,
88 pub r#type: AbiType,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ResponseDef {
93 pub ok: Box<AbiType>,
94 pub error: Box<AbiType>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct AbiVariable {
99 pub name: String,
100 pub access: String,
101 pub r#type: AbiType,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct AbiMap {
106 pub name: String,
107 pub key: AbiType,
108 pub value: AbiType,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AbiNft {
113 pub name: String,
114 pub r#type: AbiType,
115}
116
117pub async fn parse_project(contracts_dir: &Path) -> Result<Vec<ContractAbi>> {
118 use tokio::process::Command;
119
120 let clarinet_toml = contracts_dir.join("Clarinet.toml");
121 if !clarinet_toml.exists() {
122 return Err(anyhow!(
123 "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
124 ));
125 }
126
127 let project_root = contracts_dir
131 .parent()
132 .ok_or_else(|| anyhow!("Invalid contracts path"))?;
133 let script = project_root
134 .join("frontend")
135 .join("scripts")
136 .join("export-abi.mjs");
137
138 if !script.exists() {
139 return Err(anyhow!(
140 "ABI export script not found at {}. Re-scaffold or add frontend/scripts/export-abi.mjs.",
141 script.display()
142 ));
143 }
144
145 let script_abs = script
147 .canonicalize()
148 .map_err(|e| anyhow!("Cannot resolve script path {}: {e}", script.display()))?;
149
150 let output = Command::new("node")
152 .arg(&script_abs)
153 .current_dir(contracts_dir) .output()
155 .await
156 .map_err(|e| {
157 if e.kind() == std::io::ErrorKind::NotFound {
158 anyhow!("Node.js is required to export ABIs. Install from nodejs.org")
159 } else {
160 anyhow!("Failed to run export-abi script: {e}")
161 }
162 })?;
163
164 if !output.status.success() {
165 let stderr = String::from_utf8_lossy(&output.stderr);
166 return Err(anyhow!(
167 "Failed to export contract ABIs. Run clarinet check to validate contracts.\n{}",
168 if stderr.is_empty() {
169 "Script exited non-zero.".to_string()
170 } else {
171 stderr.trim().to_string()
172 }
173 ));
174 }
175
176 let stdout = String::from_utf8(output.stdout)?;
177 let stderr = String::from_utf8_lossy(&output.stderr);
178
179 let quiet = std::env::var_os("STACKSDAPP_QUIET").is_some();
181 if !quiet && !stderr.trim().is_empty() {
182 eprintln!("[export-abi] {}", stderr.trim());
183 }
184
185 let json_start = stdout.find('[').ok_or_else(|| anyhow!(
188 "export-abi.mjs produced no JSON. Run: cd contracts && node ../frontend/scripts/export-abi.mjs\nOutput: {}",
189 &stdout[..stdout.len().min(300)]
190 ))?;
191 let json = stdout[json_start..].trim();
192
193 parse_abi_list(json)
194}
195
196pub fn parse_abi_list(json: &str) -> Result<Vec<ContractAbi>> {
198 serde_json::from_str(json).map_err(|e| {
199 anyhow!(
200 "Failed to parse ABI JSON: {e}.
201 First 200 chars of output: {}",
202 &json[..json.len().min(200)]
203 )
204 })
205}
206
207pub fn parse_abi(json: &str) -> Result<ContractAbi> {
209 let abi = serde_json::from_str(json)?;
210 Ok(abi)
211}
212
213pub fn abi_type_to_ts(t: &AbiType) -> String {
215 match t {
216 AbiType::Simple(s) => match s.as_str() {
217 "uint128" | "int128" => "bigint".to_string(),
218 "bool" => "boolean".to_string(),
219 "principal" => "string".to_string(),
220 _ => "unknown".to_string(),
221 },
222 AbiType::StringAscii { .. } | AbiType::StringUtf8 { .. } => "string".to_string(),
223 AbiType::Buffer { .. } | AbiType::Buff { .. } => "Uint8Array".to_string(),
224 AbiType::List { list } => {
225 let inner = abi_type_to_ts(&list.r#type);
226 format!("Array<{inner}>")
227 }
228 AbiType::Tuple { tuple } => {
229 let fields: Vec<String> = tuple
230 .iter()
231 .map(|e| format!("{}: {}", e.name, abi_type_to_ts(&e.r#type)))
232 .collect();
233 format!("{{ {} }}", fields.join(", "))
234 }
235 AbiType::Optional { optional } => format!("{} | null", abi_type_to_ts(optional)),
236 AbiType::Response { response } => {
237 let ok = abi_type_to_ts(&response.ok);
238 let err = abi_type_to_ts(&response.error);
239 format!("{{ ok: {ok} }} | {{ error: {err} }}")
240 }
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::{abi_type_to_ts, parse_abi, parse_abi_list, AbiType};
247
248 #[test]
249 fn parse_abi_list_accepts_empty_array() {
250 let abis = parse_abi_list("[]").unwrap();
251 assert!(abis.is_empty());
252 }
253
254 #[test]
255 fn parse_abi_list_rejects_non_array_json() {
256 assert!(parse_abi_list("{}").is_err());
257 assert!(parse_abi_list("null").is_err());
258 assert!(parse_abi_list("not-json").is_err());
259 }
260
261 #[test]
262 fn parse_abi_list_rejects_truncated_json() {
263 assert!(parse_abi_list("[{\"contract_name\":").is_err());
264 }
265
266 #[test]
267 fn parse_abi_list_rejects_missing_required_fields() {
268 let json = r#"[{"contract_name":"counter"}]"#;
269 assert!(parse_abi_list(json).is_err());
270 }
271
272 #[test]
273 fn parse_abi_parses_minimal_contract() {
274 let json = r#"{
275 "contract_id": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.counter",
276 "contract_name": "counter",
277 "functions": [],
278 "variables": [],
279 "maps": [],
280 "fungible_tokens": [],
281 "non_fungible_tokens": []
282 }"#;
283 let abi = parse_abi(json).unwrap();
284 assert_eq!(abi.contract_name, "counter");
285 }
286
287 #[test]
288 fn abi_type_to_ts_maps_primitives() {
289 assert_eq!(abi_type_to_ts(&AbiType::Simple("uint128".into())), "bigint");
290 assert_eq!(abi_type_to_ts(&AbiType::Simple("bool".into())), "boolean");
291 assert_eq!(
292 abi_type_to_ts(&AbiType::Simple("unknown-type".into())),
293 "unknown"
294 );
295 }
296}