use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractAbi {
pub contract_id: String,
pub contract_name: String,
pub functions: Vec<AbiFunction>,
pub variables: Vec<AbiVariable>,
pub maps: Vec<AbiMap>,
pub fungible_tokens: Vec<String>,
pub non_fungible_tokens: Vec<AbiNft>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiFunction {
pub name: String,
pub access: FunctionAccess,
pub args: Vec<AbiArg>,
pub outputs: AbiType,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FunctionAccess {
Public,
ReadOnly,
Private,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiArg {
pub name: String,
pub r#type: AbiType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AbiType {
Simple(String),
StringAscii {
#[serde(rename = "string-ascii")]
string_ascii: StringLen,
},
StringUtf8 {
#[serde(rename = "string-utf8")]
string_utf8: StringLen,
},
Buffer {
buffer: StringLen,
},
Buff {
buff: u32,
},
List {
list: ListDef,
},
Tuple {
tuple: Vec<TupleEntry>,
},
Optional {
optional: Box<AbiType>,
},
Response {
response: ResponseDef,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StringLen {
pub length: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListDef {
pub r#type: Box<AbiType>,
pub length: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TupleEntry {
pub name: String,
pub r#type: AbiType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseDef {
pub ok: Box<AbiType>,
pub error: Box<AbiType>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiVariable {
pub name: String,
pub access: String,
pub r#type: AbiType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiMap {
pub name: String,
pub key: AbiType,
pub value: AbiType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiNft {
pub name: String,
pub r#type: AbiType,
}
pub async fn parse_project(contracts_dir: &Path) -> Result<Vec<ContractAbi>> {
use tokio::process::Command;
let clarinet_toml = contracts_dir.join("Clarinet.toml");
if !clarinet_toml.exists() {
return Err(anyhow!(
"No scaffold-stacks project found. Run from the directory created by stacksdapp new"
));
}
let project_root = contracts_dir
.parent()
.ok_or_else(|| anyhow!("Invalid contracts path"))?;
let script = project_root
.join("frontend")
.join("scripts")
.join("export-abi.mjs");
if !script.exists() {
return Err(anyhow!(
"ABI export script not found at {}. Re-scaffold or add frontend/scripts/export-abi.mjs.",
script.display()
));
}
let script_abs = script
.canonicalize()
.map_err(|e| anyhow!("Cannot resolve script path {}: {e}", script.display()))?;
let output = Command::new("node")
.arg(&script_abs)
.current_dir(contracts_dir) .output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!("Node.js is required to export ABIs. Install from nodejs.org")
} else {
anyhow!("Failed to run export-abi script: {e}")
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"Failed to export contract ABIs. Run clarinet check to validate contracts.\n{}",
if stderr.is_empty() {
"Script exited non-zero.".to_string()
} else {
stderr.trim().to_string()
}
));
}
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!("[export-abi] {}", stderr.trim());
}
let json_start = stdout.find('[').ok_or_else(|| anyhow!(
"export-abi.mjs produced no JSON. Run: cd contracts && node ../frontend/scripts/export-abi.mjs\nOutput: {}",
&stdout[..stdout.len().min(300)]
))?;
let json = stdout[json_start..].trim();
parse_abi_list(json)
}
pub fn parse_abi_list(json: &str) -> Result<Vec<ContractAbi>> {
serde_json::from_str(json).map_err(|e| {
anyhow!(
"Failed to parse ABI JSON: {e}.
First 200 chars of output: {}",
&json[..json.len().min(200)]
)
})
}
pub fn parse_abi(json: &str) -> Result<ContractAbi> {
let abi = serde_json::from_str(json)?;
Ok(abi)
}
pub fn abi_type_to_ts(t: &AbiType) -> String {
match t {
AbiType::Simple(s) => match s.as_str() {
"uint128" | "int128" => "bigint".to_string(),
"bool" => "boolean".to_string(),
"principal" => "string".to_string(),
_ => "unknown".to_string(),
},
AbiType::StringAscii { .. } | AbiType::StringUtf8 { .. } => "string".to_string(),
AbiType::Buffer { .. } | AbiType::Buff { .. } => "Uint8Array".to_string(),
AbiType::List { list } => {
let inner = abi_type_to_ts(&list.r#type);
format!("Array<{inner}>")
}
AbiType::Tuple { tuple } => {
let fields: Vec<String> = tuple
.iter()
.map(|e| format!("{}: {}", e.name, abi_type_to_ts(&e.r#type)))
.collect();
format!("{{ {} }}", fields.join(", "))
}
AbiType::Optional { optional } => format!("{} | null", abi_type_to_ts(optional)),
AbiType::Response { response } => {
let ok = abi_type_to_ts(&response.ok);
let err = abi_type_to_ts(&response.error);
format!("{{ ok: {ok} }} | {{ error: {err} }}")
}
}
}