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);
let quiet = std::env::var_os("STACKSDAPP_QUIET").is_some();
if !quiet && !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} }}")
}
}
}
#[cfg(test)]
mod tests {
use super::{abi_type_to_ts, parse_abi, parse_abi_list, AbiType};
#[test]
fn parse_abi_list_accepts_empty_array() {
let abis = parse_abi_list("[]").unwrap();
assert!(abis.is_empty());
}
#[test]
fn parse_abi_list_rejects_non_array_json() {
assert!(parse_abi_list("{}").is_err());
assert!(parse_abi_list("null").is_err());
assert!(parse_abi_list("not-json").is_err());
}
#[test]
fn parse_abi_list_rejects_truncated_json() {
assert!(parse_abi_list("[{\"contract_name\":").is_err());
}
#[test]
fn parse_abi_list_rejects_missing_required_fields() {
let json = r#"[{"contract_name":"counter"}]"#;
assert!(parse_abi_list(json).is_err());
}
#[test]
fn parse_abi_parses_minimal_contract() {
let json = r#"{
"contract_id": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.counter",
"contract_name": "counter",
"functions": [],
"variables": [],
"maps": [],
"fungible_tokens": [],
"non_fungible_tokens": []
}"#;
let abi = parse_abi(json).unwrap();
assert_eq!(abi.contract_name, "counter");
}
#[test]
fn abi_type_to_ts_maps_primitives() {
assert_eq!(abi_type_to_ts(&AbiType::Simple("uint128".into())), "bigint");
assert_eq!(abi_type_to_ts(&AbiType::Simple("bool".into())), "boolean");
assert_eq!(
abi_type_to_ts(&AbiType::Simple("unknown-type".into())),
"unknown"
);
}
}