From edd1948dec8ed982dd30cbb6011dd13755f0a386 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 20:14:13 -0700 Subject: [PATCH] feat(dao): dao_discover_scripts MCP tool + Koios discovery client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `aldabra-dao::discovery` module: - `DiscoveryClient` trait + `KoiosDiscoveryClient` impl - `discover_scripts(cfg, client, deployers)` — auto-finds: - governor_validator_ref + stake_validator_ref via deployer ref-script search - stake_st_policy from any existing stake UTxO (gov-token + non-gov-token asset) - stake_st_policy_ref via deployer search - `apply_discovery(cfg, report)` — merges into DaoConfig (never overwrites) - `script_hash_from_addr(bech32)` — extract 28-byte script hash from a script address New MCP tool: - `dao_discover_scripts { dao?, extra_deployers? }` — runs the audit logic against any registered DAO + persists the discovered fields back to the DaoConfig. Returns JSON with what was found + a gaps list for things v1 can't auto-discover (proposal_addr, proposal_st_policy). Plus 4 unit tests with stub Koios responses validating the full pipeline: script-hash extraction, StakeST discovery from stake UTxO assets, validator ref-utxo matching at deployer, apply_discovery merge semantics. WalletInner now caches `koios_base` so the discovery client can be constructed on demand without re-passing the URL through args. --- crates/aldabra-dao/src/discovery.rs | 531 ++++++++++++++++++++++++++++ crates/aldabra-dao/src/lib.rs | 1 + crates/aldabra-mcp/src/tools.rs | 71 +++- 3 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 crates/aldabra-dao/src/discovery.rs diff --git a/crates/aldabra-dao/src/discovery.rs b/crates/aldabra-dao/src/discovery.rs new file mode 100644 index 0000000..30ac29a --- /dev/null +++ b/crates/aldabra-dao/src/discovery.rs @@ -0,0 +1,531 @@ +//! Auto-discover Agora script hashes + reference UTxO refs from on-chain state. +//! +//! Closes the "user has to research and hand-populate ScriptRefs" gap by +//! running the same Koios queries the human audit at +//! `memory/audit-sulkta-agora-2026-05-05.md` performed. +//! +//! ## What we discover from the existing config +//! +//! Given a `DaoConfig` with at minimum {governor_addr, stakes_addr, +//! treasury_addr, gov_token_policy, gov_token_name_hex} we can find: +//! +//! - **governor_validator_ref** — search known deployer addresses for a UTxO +//! whose `reference_script.hash` matches the script hash extracted from +//! `governor_addr`'s bech32. +//! - **stake_validator_ref** — same pattern against `stakes_addr`'s hash. +//! - **stake_st_policy + stake_st_policy_ref** — look at any existing stake +//! UTxO at `stakes_addr` (filtered to those holding the gov token); the +//! other asset on the UTxO is the StakeST. Then locate its ref-utxo at +//! the deployer. +//! +//! ## What we DON'T cover in v1 +//! +//! - **proposal_addr / ProposalST policy** — for v1 the user provides these +//! explicitly. Discovery would require walking governor txs (CreateProposal +//! spend → output at proposal_addr), which is a heavier lift. Phase 4b. +//! - **treasury_validator_ref** — Sulkta's treasury validator wasn't found at +//! the shared deployer per the audit. Possibly deployed elsewhere or not +//! yet on chain. Phase 4c when treasury-spend ships. +//! +//! ## Deployer addresses to search +//! +//! MLabs's shared Agora deployer at `addr1w9gexmeunzsykesf42d4eqet5yvzeap6trjnflxqtkcf66g5740fw` +//! (mainnet) is the standard for Clarity-deployed DAOs. We probe the +//! addresses provided by the caller as additional deployer candidates; +//! by default we just probe the MLabs shared one. + +use serde::Deserialize; + +use crate::config::{DaoConfig, ScriptRefs}; +use crate::error::{DaoError, DaoResult}; + +/// Standard MLabs shared Agora deployer on mainnet. Hosts the parameterized +/// validators + minting policies for many Clarity DAOs (Indigo, SundaeSwap, +/// Sulkta, etc). +pub const MAINNET_AGORA_SHARED_DEPLOYER: &str = + "addr1w9gexmeunzsykesf42d4eqet5yvzeap6trjnflxqtkcf66g5740fw"; + +/// Trait for the chain reads we need. Lets tests stub Koios responses. +#[async_trait::async_trait] +pub trait DiscoveryClient: Send + Sync { + async fn address_info(&self, address: &str) -> DaoResult>; +} + +/// Koios-backed [`DiscoveryClient`] for production use. +/// +/// Mirrors the `KoiosDaoReader` shape — separate client because the trait +/// surface is different and we don't want to entangle Phase 1 reads with +/// Phase 4-prep discovery. +pub struct KoiosDiscoveryClient { + base_url: String, + http: reqwest::Client, +} + +impl KoiosDiscoveryClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client"), + } + } +} + +#[async_trait::async_trait] +impl DiscoveryClient for KoiosDiscoveryClient { + async fn address_info(&self, address: &str) -> DaoResult> { + let url = format!("{}/address_info", self.base_url); + let body = serde_json::json!({ "_addresses": [address] }); + let resp = self + .http + .post(url) + .json(&body) + .send() + .await + .map_err(|e| DaoError::Backend(format!("address_info {address}: {e}")))?; + if !resp.status().is_success() { + return Err(DaoError::Backend(format!( + "address_info {address}: HTTP {}", + resp.status() + ))); + } + resp.json::>() + .await + .map_err(|e| DaoError::Backend(format!("address_info {address} parse: {e}"))) + } +} + +/// Subset of Koios `address_info` JSON we need. +#[derive(Debug, Deserialize, Clone)] +pub struct AddressInfo { + #[allow(dead_code)] + pub address: String, + pub utxo_set: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AddressUtxo { + pub tx_hash: String, + pub tx_index: u32, + #[serde(default)] + pub asset_list: Option>, + #[serde(default)] + pub reference_script: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct UtxoAsset { + pub policy_id: String, + pub asset_name: String, + pub quantity: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct RefScript { + pub hash: String, + #[allow(dead_code)] + #[serde(rename = "type")] + pub kind: Option, + #[allow(dead_code)] + pub size: Option, +} + +/// What `discover_scripts` filled in vs. left blank. +#[derive(Debug, Clone, Default)] +pub struct DiscoveryReport { + pub governor_validator_ref: Option, + pub stake_validator_ref: Option, + pub stake_st_policy: Option, + pub stake_st_policy_ref: Option, + /// Things we couldn't auto-find. Show these to the user so they know + /// what to provide manually before running write tools. + pub gaps: Vec, +} + +/// Decode a bech32 script address → 28-byte script-hash hex. +pub fn script_hash_from_addr(bech32: &str) -> DaoResult { + use bech32::FromBase32; + let (_hrp, data, _variant) = + bech32::decode(bech32).map_err(|e| DaoError::Address(format!("bech32 decode: {e}")))?; + let bytes = Vec::::from_base32(&data) + .map_err(|e| DaoError::Address(format!("bech32 base32: {e}")))?; + if bytes.len() < 29 { + return Err(DaoError::Address(format!( + "address too short ({} bytes)", + bytes.len() + ))); + } + // First byte = network/type header; next 28 = script hash. + Ok(hex::encode(&bytes[1..29])) +} + +/// Auto-discover the script-ref UTxOs + StakeST policy for a DAO. +/// +/// Caller supplies a [`DiscoveryClient`] (typically a Koios wrapper) and the +/// config to inspect. Returns a [`DiscoveryReport`] with whatever was found +/// + a list of `gaps` (things the user must still provide manually). +pub async fn discover_scripts( + cfg: &DaoConfig, + client: &dyn DiscoveryClient, + deployer_addresses: &[&str], +) -> DaoResult { + let mut report = DiscoveryReport::default(); + + // 1. Validator script hashes from address bech32. + let governor_hash = script_hash_from_addr(&cfg.governor_addr)?; + let stakes_hash = script_hash_from_addr(&cfg.stakes_addr)?; + + // 2. StakeST policy from any stake UTxO at stakes_addr. + // + // A stake UTxO carries (gov_token, qty) + (stake_st_token, 1). Filter to + // ones that have BOTH our gov-token AND a non-gov-token asset; the + // non-gov-token's policy_id is the StakeST policy. + match client.address_info(&cfg.stakes_addr).await { + Ok(infos) => { + let utxos = infos.into_iter().next().map(|i| i.utxo_set).unwrap_or_default(); + let mut found_stake_st = None; + for u in &utxos { + let assets = match &u.asset_list { + Some(a) => a, + None => continue, + }; + let has_gov = assets + .iter() + .any(|a| a.policy_id == cfg.gov_token_policy); + if !has_gov { + continue; + } + if let Some(other) = assets.iter().find(|a| a.policy_id != cfg.gov_token_policy) { + found_stake_st = Some(other.policy_id.clone()); + break; + } + } + if let Some(p) = found_stake_st { + report.stake_st_policy = Some(p); + } else { + report.gaps.push( + "stake_st_policy: no stakes-addr UTxO carries (gov_token + StakeST) — \ + either no stakes exist yet OR stakes_addr is wrong" + .into(), + ); + } + } + Err(e) => report + .gaps + .push(format!("stake_st_policy: address_info failed for stakes_addr: {e}")), + } + + // 3. Reference-script UTxOs at the deployers. + // + // For each deployer address, fetch its UTxO set. Iterate UTxOs, match + // each `reference_script.hash` against our targets: + // - governor validator → governor_validator_ref + // - stake validator → stake_validator_ref + // - stake_st policy → stake_st_policy_ref (if we found the policy) + + let stake_st_target = report.stake_st_policy.clone(); + + for &deployer in deployer_addresses.iter() { + let infos = match client.address_info(deployer).await { + Ok(v) => v, + Err(e) => { + report.gaps.push(format!( + "deployer {deployer} probe failed: {e}" + )); + continue; + } + }; + let utxos = infos.into_iter().next().map(|i| i.utxo_set).unwrap_or_default(); + + for u in &utxos { + let rs = match &u.reference_script { + Some(r) => r, + None => continue, + }; + let utxo_ref = format!("{}#{}", u.tx_hash, u.tx_index); + + if rs.hash == governor_hash && report.governor_validator_ref.is_none() { + report.governor_validator_ref = Some(utxo_ref.clone()); + } + if rs.hash == stakes_hash && report.stake_validator_ref.is_none() { + report.stake_validator_ref = Some(utxo_ref.clone()); + } + if let Some(ref target) = stake_st_target { + if &rs.hash == target && report.stake_st_policy_ref.is_none() { + report.stake_st_policy_ref = Some(utxo_ref.clone()); + } + } + } + } + + if report.governor_validator_ref.is_none() { + report.gaps.push(format!( + "governor_validator_ref: hash {} not found at any provided deployer address", + &governor_hash[..16] + )); + } + if report.stake_validator_ref.is_none() { + report.gaps.push(format!( + "stake_validator_ref: hash {} not found at any provided deployer address", + &stakes_hash[..16] + )); + } + if report.stake_st_policy.is_some() && report.stake_st_policy_ref.is_none() { + report.gaps.push( + "stake_st_policy_ref: policy id discovered but no ref-utxo found at deployers" + .into(), + ); + } + + // Always add gaps for things v1 doesn't auto-discover. + if cfg.proposal_addr.is_none() { + report + .gaps + .push("proposal_addr: not auto-discovered in v1; provide via dao_register".into()); + } + if cfg.proposal_st_policy.is_none() { + report.gaps.push( + "proposal_st_policy: not auto-discovered in v1; provide via dao_register".into(), + ); + } + + Ok(report) +} + +/// Merge a [`DiscoveryReport`] into a [`DaoConfig`], filling in any field +/// the report discovered. Returns the merged config — caller persists. +pub fn apply_discovery(cfg: &mut DaoConfig, report: &DiscoveryReport) { + if let Some(p) = &report.stake_st_policy { + if cfg.stake_st_policy.is_none() { + cfg.stake_st_policy = Some(p.clone()); + } + } + if let Some(r) = &report.governor_validator_ref { + if cfg.script_refs.governor_validator.is_none() { + cfg.script_refs.governor_validator = Some(r.clone()); + } + } + if let Some(r) = &report.stake_validator_ref { + if cfg.script_refs.stake_validator.is_none() { + cfg.script_refs.stake_validator = Some(r.clone()); + } + } + if let Some(r) = &report.stake_st_policy_ref { + if cfg.script_refs.stake_st_policy.is_none() { + cfg.script_refs.stake_st_policy = Some(r.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_script_hash_from_governor_addr() { + let h = script_hash_from_addr("addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy") + .unwrap(); + assert_eq!(h, "d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7"); + } + + #[test] + fn extracts_script_hash_from_real_stakes_addr() { + let h = + script_hash_from_addr("addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8") + .unwrap(); + assert_eq!(h, "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"); + } + + #[test] + fn extracts_script_hash_from_treasury_addr() { + let h = script_hash_from_addr("addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y") + .unwrap(); + assert_eq!(h, "9461852b2a4be42055e5083b6595e46af48930183bbe969609fea668"); + } + + /// Stub client returning canned address_info for testing the discovery + /// pipeline without hitting Koios. + struct StubClient { + responses: std::collections::HashMap>, + } + + #[async_trait::async_trait] + impl DiscoveryClient for StubClient { + async fn address_info(&self, address: &str) -> DaoResult> { + Ok(self + .responses + .get(address) + .cloned() + .unwrap_or_default()) + } + } + + fn sulkta_cfg() -> DaoConfig { + DaoConfig { + name: "sulkta".into(), + description: None, + governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(), + stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(), + treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(), + gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(), + gov_token_name_hex: "546572726170696e".into(), + initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0" + .into(), + max_cosigners: 5, + treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(), + network: crate::config::DaoNetwork::Mainnet, + proposal_addr: None, + stake_st_policy: None, + proposal_st_policy: None, + script_refs: ScriptRefs::default(), + } + } + + #[tokio::test] + async fn discovers_stake_st_from_existing_stake() { + let cfg = sulkta_cfg(); + let mut responses = std::collections::HashMap::new(); + // A fake stake UTxO at stakes_addr carrying gov-token + StakeST. + responses.insert( + cfg.stakes_addr.clone(), + vec![AddressInfo { + address: cfg.stakes_addr.clone(), + utxo_set: vec![AddressUtxo { + tx_hash: "deadbeef".repeat(8), + tx_index: 0, + asset_list: Some(vec![ + UtxoAsset { + policy_id: cfg.gov_token_policy.clone(), + asset_name: cfg.gov_token_name_hex.clone(), + quantity: "50".into(), + }, + UtxoAsset { + policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), + asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(), + quantity: "1".into(), + }, + ]), + reference_script: None, + }], + }], + ); + // Empty deployer for this test — we just want StakeST policy id. + responses.insert( + MAINNET_AGORA_SHARED_DEPLOYER.into(), + vec![AddressInfo { + address: MAINNET_AGORA_SHARED_DEPLOYER.into(), + utxo_set: vec![], + }], + ); + + let client = StubClient { responses }; + let report = discover_scripts(&cfg, &client, &[MAINNET_AGORA_SHARED_DEPLOYER]) + .await + .unwrap(); + assert_eq!( + report.stake_st_policy.as_deref(), + Some("732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696") + ); + } + + #[tokio::test] + async fn finds_validator_refs_at_deployer() { + let cfg = sulkta_cfg(); + let governor_hash = "d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7"; + let stake_hash = "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"; + + let mut responses = std::collections::HashMap::new(); + responses.insert(cfg.stakes_addr.clone(), vec![AddressInfo { + address: cfg.stakes_addr.clone(), + utxo_set: vec![], + }]); + responses.insert( + MAINNET_AGORA_SHARED_DEPLOYER.into(), + vec![AddressInfo { + address: MAINNET_AGORA_SHARED_DEPLOYER.into(), + utxo_set: vec![ + // Governor validator ref + AddressUtxo { + tx_hash: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea" + .into(), + tx_index: 3, + asset_list: None, + reference_script: Some(RefScript { + hash: governor_hash.into(), + kind: Some("plutusV2".into()), + size: Some(7213), + }), + }, + // Stake validator ref + AddressUtxo { + tx_hash: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea" + .into(), + tx_index: 2, + asset_list: None, + reference_script: Some(RefScript { + hash: stake_hash.into(), + kind: Some("plutusV2".into()), + size: Some(5182), + }), + }, + // Random other ref-utxo (different DAO's script — should be ignored) + AddressUtxo { + tx_hash: "0000000000000000000000000000000000000000000000000000000000000000" + .into(), + tx_index: 0, + asset_list: None, + reference_script: Some(RefScript { + hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff".into(), + kind: Some("plutusV2".into()), + size: Some(1024), + }), + }, + ], + }], + ); + + let client = StubClient { responses }; + let report = discover_scripts(&cfg, &client, &[MAINNET_AGORA_SHARED_DEPLOYER]) + .await + .unwrap(); + assert_eq!( + report.governor_validator_ref.as_deref(), + Some("479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea#3") + ); + assert_eq!( + report.stake_validator_ref.as_deref(), + Some("479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea#2") + ); + } + + #[test] + fn apply_discovery_merges_into_config() { + let mut cfg = sulkta_cfg(); + let report = DiscoveryReport { + governor_validator_ref: Some("aa#1".into()), + stake_validator_ref: Some("bb#2".into()), + stake_st_policy: Some("ccdd".into()), + stake_st_policy_ref: Some("ee#0".into()), + gaps: vec![], + }; + apply_discovery(&mut cfg, &report); + assert_eq!(cfg.script_refs.governor_validator.as_deref(), Some("aa#1")); + assert_eq!(cfg.script_refs.stake_validator.as_deref(), Some("bb#2")); + assert_eq!(cfg.stake_st_policy.as_deref(), Some("ccdd")); + assert_eq!(cfg.script_refs.stake_st_policy.as_deref(), Some("ee#0")); + } + + #[test] + fn apply_discovery_doesnt_overwrite_existing() { + let mut cfg = sulkta_cfg(); + cfg.stake_st_policy = Some("preexisting".into()); + let report = DiscoveryReport { + stake_st_policy: Some("would_overwrite".into()), + ..Default::default() + }; + apply_discovery(&mut cfg, &report); + assert_eq!(cfg.stake_st_policy.as_deref(), Some("preexisting")); + } +} diff --git a/crates/aldabra-dao/src/lib.rs b/crates/aldabra-dao/src/lib.rs index 8165ab7..33718ac 100644 --- a/crates/aldabra-dao/src/lib.rs +++ b/crates/aldabra-dao/src/lib.rs @@ -28,6 +28,7 @@ pub mod agora; pub mod builder; pub mod config; +pub mod discovery; pub mod error; pub mod reader; diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index a7b4bcb..c2cd263 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -35,6 +35,9 @@ use aldabra_dao::builder::proposal_create::{ WalletUtxo as DaoWalletUtxo, }; use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; +use aldabra_dao::discovery::{ + apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER, +}; use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD; use aldabra_core::{ @@ -90,6 +93,9 @@ struct WalletInner { /// Reader-only Koios client for DAO-shape queries. Reuses the /// koios_base; separate from `chain` so the trait surface stays clean. dao_reader: KoiosDaoReader, + /// Cached Koios base url so `dao_discover_scripts` can spin up a + /// `KoiosDiscoveryClient` on demand without a re-construction call. + koios_base: String, } impl WalletService { @@ -111,7 +117,8 @@ impl WalletService { stake_key, max_send_lovelace, dao_store: DaoStore::new(&data_dir), - dao_reader: KoiosDaoReader::new(koios_base), + dao_reader: KoiosDaoReader::new(koios_base.clone()), + koios_base, }), } } @@ -1503,6 +1510,56 @@ impl WalletService { Ok(serde_json::json!({ "dao": cfg.name, "stakes": arr }).to_string()) } + #[tool( + name = "dao_discover_scripts", + description = "Auto-populate a DAO's ScriptRefs + StakeST policy by inspecting on-chain state. v1 fills in: governor_validator_ref, stake_validator_ref, stake_st_policy, stake_st_policy_ref. proposal_addr + proposal_st_policy still require manual entry (v1 limitation). Searches the MLabs shared Agora deployer (`addr1w9gexmeunzsy...`) by default; pass extra deployer addresses if your DAO's scripts live elsewhere. Args: dao (optional), extra_deployers (optional list of bech32). Returns JSON {discovered, gaps, updated_config}." + )] + async fn dao_discover_scripts( + &self, + #[tool(aggr)] DaoDiscoverArgs { + dao, + extra_deployers, + }: DaoDiscoverArgs, + ) -> Result { + let mut cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + + // Build the deployer search list: MLabs's shared one + any caller-supplied. + let extra: Vec = extra_deployers.unwrap_or_default(); + let mut deployers: Vec<&str> = vec![MAINNET_AGORA_SHARED_DEPLOYER]; + deployers.extend(extra.iter().map(|s| s.as_str())); + + // Use the same Koios base URL as the wallet's chain backend. + let client = KoiosDiscoveryClient::new(self.inner.koios_base.clone()); + let report = discover_scripts(&cfg, &client, &deployers) + .await + .map_err(|e| e.to_string())?; + + apply_discovery(&mut cfg, &report); + + // Persist. + self.inner + .dao_store + .register(&cfg) + .map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "dao": cfg.name, + "discovered": { + "stake_st_policy": report.stake_st_policy, + "governor_validator_ref": report.governor_validator_ref, + "stake_validator_ref": report.stake_validator_ref, + "stake_st_policy_ref": report.stake_st_policy_ref, + }, + "gaps": report.gaps, + "config_after": cfg, + }) + .to_string()) + } + #[tool( name = "dao_proposal_create_unsigned", description = "Build (but DO NOT submit) an unsigned proposal-creation tx for the given DAO. Returns the CBOR-hex of the unsigned tx body + the new proposal_id. Currently supports InfoOnly proposals only — TreasuryWithdrawal effect path lands in Phase 4c. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx. Args: dao (optional — defaults to active), fee_lovelace (suggested ~3_000_000 for v1; refine via koios tx_evaluate), starting_time_ms (POSIX millis to embed in ProposalDatum.starting_time; pass current chain tip's slot * 1000 + epoch start)." @@ -1739,6 +1796,18 @@ pub struct DaoShowArgs { pub dao: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoDiscoverArgs { + /// Named DAO. Falls through to active if omitted. + #[serde(default)] + pub dao: Option, + /// Extra deployer addresses to search (bech32) on top of the + /// default MLabs shared deployer. Useful for DAOs whose scripts + /// were deployed to a private address. + #[serde(default)] + pub extra_deployers: Option>, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DaoProposalCreateArgs { /// Named DAO. Falls through to active if omitted.