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:
parent
5913b9266a
commit
edd1948dec
3 changed files with 602 additions and 1 deletions
531
crates/aldabra-dao/src/discovery.rs
Normal file
531
crates/aldabra-dao/src/discovery.rs
Normal file
|
|
@ -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<Vec<AddressInfo>>;
|
||||
}
|
||||
|
||||
/// 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<String>) -> 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<Vec<AddressInfo>> {
|
||||
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::<Vec<AddressInfo>>()
|
||||
.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<AddressUtxo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AddressUtxo {
|
||||
pub tx_hash: String,
|
||||
pub tx_index: u32,
|
||||
#[serde(default)]
|
||||
pub asset_list: Option<Vec<UtxoAsset>>,
|
||||
#[serde(default)]
|
||||
pub reference_script: Option<RefScript>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[allow(dead_code)]
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// What `discover_scripts` filled in vs. left blank.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiscoveryReport {
|
||||
pub governor_validator_ref: Option<String>,
|
||||
pub stake_validator_ref: Option<String>,
|
||||
pub stake_st_policy: Option<String>,
|
||||
pub stake_st_policy_ref: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Decode a bech32 script address → 28-byte script-hash hex.
|
||||
pub fn script_hash_from_addr(bech32: &str) -> DaoResult<String> {
|
||||
use bech32::FromBase32;
|
||||
let (_hrp, data, _variant) =
|
||||
bech32::decode(bech32).map_err(|e| DaoError::Address(format!("bech32 decode: {e}")))?;
|
||||
let bytes = Vec::<u8>::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<DiscoveryReport> {
|
||||
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<String, Vec<AddressInfo>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiscoveryClient for StubClient {
|
||||
async fn address_info(&self, address: &str) -> DaoResult<Vec<AddressInfo>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
pub mod agora;
|
||||
pub mod builder;
|
||||
pub mod config;
|
||||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod reader;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue