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 { buffer: StringLen },
54 Buff { buff: u32 },
56 List { list: ListDef },
57 Tuple { tuple: Vec<TupleEntry> },
58 Optional { optional: Box<AbiType> },
59 Response { response: ResponseDef },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct StringLen {
64 pub length: u32,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ListDef {
69 pub r#type: Box<AbiType>,
70 pub length: u32,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TupleEntry {
75 pub name: String,
76 pub r#type: AbiType,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ResponseDef {
81 pub ok: Box<AbiType>,
82 pub error: Box<AbiType>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct AbiVariable {
87 pub name: String,
88 pub access: String,
89 pub r#type: AbiType,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct AbiMap {
94 pub name: String,
95 pub key: AbiType,
96 pub value: AbiType,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AbiNft {
101 pub name: String,
102 pub r#type: AbiType,
103}
104
105pub async fn parse_project(contracts_dir: &Path) -> Result<Vec<ContractAbi>> {
106 use tokio::process::Command;
107
108 let clarinet_toml = contracts_dir.join("Clarinet.toml");
109 if !clarinet_toml.exists() {
110 return Err(anyhow!(
111 "No scaffold-stacks project found. Run from the directory created by stacks-dapp new"
112 ));
113 }
114
115 let project_root = contracts_dir
119 .parent()
120 .ok_or_else(|| anyhow!("Invalid contracts path"))?;
121 let script = project_root
122 .join("frontend")
123 .join("scripts")
124 .join("export-abi.mjs");
125
126 if !script.exists() {
127 return Err(anyhow!(
128 "ABI export script not found at {}. Re-scaffold or add frontend/scripts/export-abi.mjs.",
129 script.display()
130 ));
131 }
132
133 let script_abs = script
135 .canonicalize()
136 .map_err(|e| anyhow!("Cannot resolve script path {}: {e}", script.display()))?;
137
138 let output = Command::new("node")
140 .arg(&script_abs)
141 .current_dir(contracts_dir) .output()
143 .await
144 .map_err(|e| {
145 if e.kind() == std::io::ErrorKind::NotFound {
146 anyhow!("Node.js is required to export ABIs. Install from nodejs.org")
147 } else {
148 anyhow!("Failed to run export-abi script: {e}")
149 }
150 })?;
151
152 if !output.status.success() {
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 return Err(anyhow!(
155 "Failed to export contract ABIs. Run clarinet check to validate contracts.\n{}",
156 if stderr.is_empty() {
157 "Script exited non-zero.".to_string()
158 } else {
159 stderr.trim().to_string()
160 }
161 ));
162 }
163
164 let stdout = String::from_utf8(output.stdout)?;
165 let stderr = String::from_utf8_lossy(&output.stderr);
166
167 if !stderr.trim().is_empty() {
169 eprintln!("[export-abi] {}", stderr.trim());
170 }
171
172 let json_start = stdout.find('[').ok_or_else(|| anyhow!(
175 "export-abi.mjs produced no JSON. Run: cd contracts && node ../frontend/scripts/export-abi.mjs\nOutput: {}",
176 &stdout[..stdout.len().min(300)]
177 ))?;
178 let json = stdout[json_start..].trim();
179
180 parse_abi_list(json)
181}
182
183pub fn parse_abi_list(json: &str) -> Result<Vec<ContractAbi>> {
185 serde_json::from_str(json).map_err(|e| {
186 anyhow!(
187 "Failed to parse ABI JSON: {e}.
188 First 200 chars of output: {}",
189 &json[..json.len().min(200)]
190 )
191 })
192}
193
194pub fn parse_abi(json: &str) -> Result<ContractAbi> {
196 let abi = serde_json::from_str(json)?;
197 Ok(abi)
198}
199
200pub fn abi_type_to_ts(t: &AbiType) -> String {
202 match t {
203 AbiType::Simple(s) => match s.as_str() {
204 "uint128" | "int128" => "bigint".to_string(),
205 "bool" => "boolean".to_string(),
206 "principal" => "string".to_string(),
207 _ => "unknown".to_string(),
208 },
209 AbiType::StringAscii { .. } | AbiType::StringUtf8 { .. } => "string".to_string(),
210 AbiType::Buffer { .. } | AbiType::Buff { .. } => "Uint8Array".to_string(),
211 AbiType::List { list } => {
212 let inner = abi_type_to_ts(&list.r#type);
213 format!("Array<{inner}>")
214 }
215 AbiType::Tuple { tuple } => {
216 let fields: Vec<String> = tuple
217 .iter()
218 .map(|e| format!("{}: {}", e.name, abi_type_to_ts(&e.r#type)))
219 .collect();
220 format!("{{ {} }}", fields.join(", "))
221 }
222 AbiType::Optional { optional } => format!("{} | null", abi_type_to_ts(optional)),
223 AbiType::Response { response } => {
224 let ok = abi_type_to_ts(&response.ok);
225 let err = abi_type_to_ts(&response.error);
226 format!("{{ ok: {ok} }} | {{ error: {err} }}")
227 }
228 }
229}