feat(dao): dao_discover_scripts MCP tool + Koios discovery client

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.
This commit is contained in:
Sulkta 2026-05-05 20:14:13 -07:00
parent bd62894aa8
commit 69f93339fd
3 changed files with 602 additions and 1 deletions

View file

@ -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<String, String> {
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<String> = 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<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoDiscoverArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// 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<Vec<String>>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoProposalCreateArgs {
/// Named DAO. Falls through to active if omitted.