diff --git a/crates/aldabra-mcp/Cargo.toml b/crates/aldabra-mcp/Cargo.toml index 94b5f64..0aba77f 100644 --- a/crates/aldabra-mcp/Cargo.toml +++ b/crates/aldabra-mcp/Cargo.toml @@ -20,6 +20,15 @@ path = "src/main.rs" [dependencies] aldabra-core = { path = "../aldabra-core" } aldabra-chain = { path = "../aldabra-chain" } +aldabra-dao = { path = "../aldabra-dao" } + +# Used directly in tools.rs to decode the wallet's bech32 address into a +# payment-credential hash (so `dao_my_stake` can match against StakeDatum.owner). +# Comes in transitively via aldabra-core too; declared here for clarity. +pallas-addresses = { workspace = true } + +# `hex::encode` for rendering pkh/script-hash bytes in dao_* JSON output. +hex = "0.4" tokio = { workspace = true } anyhow = { workspace = true } diff --git a/crates/aldabra-mcp/src/main.rs b/crates/aldabra-mcp/src/main.rs index ceec207..e6c94d3 100644 --- a/crates/aldabra-mcp/src/main.rs +++ b/crates/aldabra-mcp/src/main.rs @@ -134,6 +134,7 @@ async fn run() -> Result<()> { payment_key, stake_key, cfg.max_send_lovelace, + cfg.data_dir.clone(), ); let server = service .serve(stdio()) diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 6b16039..f878d82 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -25,9 +25,13 @@ //! - `Result` lets us surface chain / build errors //! as MCP tool-call errors instead of crashing the daemon. +use std::path::PathBuf; use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; +use aldabra_dao::agora::stake::Credential as DaoCredential; +use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore}; +use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD; use aldabra_core::{ add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata, @@ -76,6 +80,12 @@ struct WalletInner { payment_key: PaymentKey, stake_key: StakeKey, max_send_lovelace: u64, + /// Per-DAO config store rooted at `/daos/`. See + /// `aldabra_dao::config::DaoStore` — load/save/active selector. + dao_store: DaoStore, + /// Reader-only Koios client for DAO-shape queries. Reuses the + /// koios_base; separate from `chain` so the trait surface stays clean. + dao_reader: KoiosDaoReader, } impl WalletService { @@ -86,19 +96,40 @@ impl WalletService { payment_key: PaymentKey, stake_key: StakeKey, max_send_lovelace: u64, + data_dir: PathBuf, ) -> Self { Self { inner: Arc::new(WalletInner { network, address, - chain: KoiosClient::new(koios_base), + chain: KoiosClient::new(koios_base.clone()), payment_key, stake_key, max_send_lovelace, + dao_store: DaoStore::new(&data_dir), + dao_reader: KoiosDaoReader::new(koios_base), }), } } + /// Extract the wallet's payment-credential hash from the bech32 + /// address. Used by `dao_my_stake` to match against + /// `StakeDatum.owner`. Returns the 28-byte pkh. + fn wallet_pkh(&self) -> Result, String> { + use pallas_addresses::{Address, ShelleyPaymentPart}; + let addr = Address::from_bech32(&self.inner.address) + .map_err(|e| format!("address parse: {e}"))?; + match addr { + Address::Shelley(s) => match s.payment() { + ShelleyPaymentPart::Key(h) => Ok(h.as_ref().to_vec()), + ShelleyPaymentPart::Script(_) => { + Err("wallet address is script-credentialed; can't be a stake owner".into()) + } + }, + _ => Err("wallet address is not Shelley-era; unsupported".into()), + } + } + /// Reject if `lovelace` exceeds the wallet's hard cap unless /// `force=true`. Used by every tool that moves lovelace to a /// non-wallet destination — wallet_send, wallet_mint, @@ -1266,6 +1297,307 @@ impl WalletService { .await .map_err(|e| format!("koios: {e}")) } + + // ─── DAO management — `$ALDABRA_DATA/daos/` config files ───────────────── + // + // These are filesystem-only — no chain calls. Cheap to invoke; users can + // register Bob's DAO + Alice's DAO + Sulkta in one session and switch + // between them with `dao_use`. + + #[tool( + name = "dao_register", + description = "Register a DAO config (Sulkta, Bob's, etc) at $ALDABRA_DATA/daos/.json. First-registered DAO becomes active automatically. Args: name (lowercase letters/digits/underscore/dash), governor_addr, stakes_addr, treasury_addr (all bech32), gov_token_policy (56 hex chars), gov_token_name_hex (hex of asset name), initial_spend (txhash#index, the Agora bootstrap tx ref), max_cosigners (u32), treasury_ref_config (56 hex chars). Optional: description, network (mainnet/preprod/preview, default mainnet)." + )] + async fn dao_register( + &self, + #[tool(aggr)] DaoRegisterArgs { + name, + description, + governor_addr, + stakes_addr, + treasury_addr, + gov_token_policy, + gov_token_name_hex, + initial_spend, + max_cosigners, + treasury_ref_config, + network, + }: DaoRegisterArgs, + ) -> Result { + let cfg = DaoConfig { + name: name.clone(), + description, + governor_addr, + stakes_addr, + treasury_addr, + gov_token_policy, + gov_token_name_hex, + initial_spend, + max_cosigners, + treasury_ref_config, + network: match network.as_deref() { + Some("preprod") => DaoNetwork::Preprod, + Some("preview") => DaoNetwork::Preview, + _ => DaoNetwork::Mainnet, + }, + }; + self.inner + .dao_store + .register(&cfg) + .map_err(|e| e.to_string())?; + Ok(format!("registered DAO {name:?}")) + } + + #[tool( + name = "dao_list", + description = "List all registered DAO config names (sorted) plus the currently active one. Returns JSON {active: \"\"|null, all: [...]}." + )] + async fn dao_list(&self) -> Result { + let all = self + .inner + .dao_store + .list() + .map_err(|e| e.to_string())?; + let active = self.inner.dao_store.get_active().ok().map(|a| a.name().to_string()); + Ok(serde_json::json!({ "active": active, "all": all }).to_string()) + } + + #[tool( + name = "dao_use", + description = "Set the active DAO. Subsequent dao_* calls without an explicit `dao` arg target this one. Must already be registered. Args: name (string)." + )] + async fn dao_use( + &self, + #[tool(aggr)] DaoUseArgs { name }: DaoUseArgs, + ) -> Result { + self.inner + .dao_store + .set_active(&name) + .map_err(|e| e.to_string())?; + Ok(format!("active DAO is now {name:?}")) + } + + #[tool( + name = "dao_remove", + description = "Delete a registered DAO config. If it was the active DAO, clears active. Doesn't touch chain — the DAO continues to exist on Cardano. Args: name (string)." + )] + async fn dao_remove( + &self, + #[tool(aggr)] DaoUseArgs { name }: DaoUseArgs, + ) -> Result { + self.inner + .dao_store + .remove(&name) + .map_err(|e| e.to_string())?; + Ok(format!("removed DAO {name:?}")) + } + + #[tool( + name = "dao_show", + description = "Return the full DaoConfig for a named DAO (or the active one if `dao` is omitted). Returns JSON of every config field — useful for audit + seeing what's wired up. Args: dao (optional string)." + )] + async fn dao_show( + &self, + #[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + serde_json::to_string(&cfg).map_err(|e| format!("serialize: {e}")) + } + + // ─── DAO live-state reads ──────────────────────────────────────────────── + + #[tool( + name = "dao_governor_state", + description = "Read the live GovernorDatum for a DAO. Returns the singleton governor UTxO ref + decoded thresholds (execute/create/toVoting/vote/cosign GT amounts), nextProposalId, timing config (draft/voting/locking/executing periods in ms), and the per-stake proposal-creation cap. Args: dao (optional — defaults to active)." + )] + async fn dao_governor_state( + &self, + #[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + let (utxo_ref, datum) = self + .inner + .dao_reader + .get_governor(&cfg) + .await + .map_err(|e| e.to_string())?; + Ok(serde_json::json!({ + "dao": cfg.name, + "governor_utxo": utxo_ref, + "next_proposal_id": datum.next_proposal_id, + "thresholds": { + "execute": datum.proposal_thresholds.execute, + "create": datum.proposal_thresholds.create, + "to_voting": datum.proposal_thresholds.to_voting, + "vote": datum.proposal_thresholds.vote, + "cosign": datum.proposal_thresholds.cosign, + }, + "timing_ms": { + "draft": datum.proposal_timings.draft_time, + "voting": datum.proposal_timings.voting_time, + "locking": datum.proposal_timings.locking_time, + "executing": datum.proposal_timings.executing_time, + "min_stake_voting": datum.proposal_timings.min_stake_voting_time, + "voting_time_range_max_width": datum.proposal_timings.voting_time_range_max_width, + }, + "create_proposal_time_range_max_width_ms": datum.create_proposal_time_range_max_width, + "max_proposals_per_stake": datum.maximum_created_proposals_per_stake, + }) + .to_string()) + } + + #[tool( + name = "dao_stake_list", + description = "List all live stakes for a DAO (filtered by gov-token policy — the shared MLabs stakes addr serves many DAOs). Returns JSON array of {utxo_ref, owner_pkh_hex, owner_kind (\"PubKey\"|\"Script\"), staked_amount, gov_token_quantity, lovelace, delegated_to_pkh_hex, locked_by: [...]}. Args: dao (optional — defaults to active)." + )] + async fn dao_stake_list( + &self, + #[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + let stakes = self + .inner + .dao_reader + .list_stakes(&cfg) + .await + .map_err(|e| e.to_string())?; + let arr: Vec = stakes + .into_iter() + .map(|s| stake_utxo_to_json(&s)) + .collect(); + Ok(serde_json::json!({ "dao": cfg.name, "stakes": arr }).to_string()) + } + + #[tool( + name = "dao_my_stake", + description = "Filter dao_stake_list to just the stake owned by THIS wallet (by matching the wallet's payment-credential hash against StakeDatum.owner). Returns the same shape as dao_stake_list but with at most one entry. If the wallet has no stake yet, returns an empty stakes array. Args: dao (optional — defaults to active)." + )] + async fn dao_my_stake( + &self, + #[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + let pkh = self.wallet_pkh()?; + let stakes = self + .inner + .dao_reader + .list_stakes(&cfg) + .await + .map_err(|e| e.to_string())?; + let mine: Vec = stakes + .into_iter() + .filter(|s| match &s.datum.owner { + DaoCredential::PubKey(h) => h == &pkh, + DaoCredential::Script(_) => false, + }) + .map(|s| stake_utxo_to_json(&s)) + .collect(); + Ok(serde_json::json!({ + "dao": cfg.name, + "wallet_pkh": hex::encode(&pkh), + "stakes": mine, + }) + .to_string()) + } +} + +// ─── DAO arg structs ──────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoRegisterArgs { + /// Lowercase letters, digits, `_`, `-`. Becomes the filename. + pub name: String, + /// Free-form description for humans. + #[serde(default)] + pub description: Option, + pub governor_addr: String, + pub stakes_addr: String, + pub treasury_addr: String, + /// 56 hex chars (28 bytes). + pub gov_token_policy: String, + /// Hex-encoded asset name (e.g. "546572726170696e" for "Terrapin"). + pub gov_token_name_hex: String, + /// `txhash#index` — the Agora bootstrap tx ref that identifies the DAO. + pub initial_spend: String, + pub max_cosigners: u32, + /// 56 hex chars (28 bytes). + pub treasury_ref_config: String, + /// "mainnet" | "preprod" | "preview". Default mainnet. + #[serde(default)] + pub network: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoUseArgs { + pub name: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoShowArgs { + /// Named DAO. Falls through to the active one if omitted. + #[serde(default)] + pub dao: Option, +} + +/// Render a [`aldabra_dao::reader::StakeUtxo`] as a JSON object for tool output. +/// +/// Formatted as a free function rather than `impl Serialize for StakeUtxo` to +/// keep the dao crate's wire shape decoupled from the MCP tool surface — a +/// future Phase 4 may add fields to StakeUtxo that we don't want to expose. +fn stake_utxo_to_json(s: &aldabra_dao::reader::StakeUtxo) -> serde_json::Value { + use aldabra_dao::agora::stake::ProposalAction; + let (owner_kind, owner_pkh) = match &s.datum.owner { + DaoCredential::PubKey(h) => ("PubKey", hex::encode(h)), + DaoCredential::Script(h) => ("Script", hex::encode(h)), + }; + let delegated = s.datum.delegated_to.as_ref().map(|c| match c { + DaoCredential::PubKey(h) => serde_json::json!({"kind":"PubKey","hex": hex::encode(h)}), + DaoCredential::Script(h) => serde_json::json!({"kind":"Script","hex": hex::encode(h)}), + }); + let locks: Vec = s + .datum + .locked_by + .iter() + .map(|l| { + let action = match &l.action { + ProposalAction::Created => serde_json::json!({"kind":"Created"}), + ProposalAction::Voted { result_tag, posix_time } => serde_json::json!({ + "kind":"Voted","result_tag": result_tag, "posix_time_ms": posix_time, + }), + ProposalAction::Cosigned => serde_json::json!({"kind":"Cosigned"}), + }; + serde_json::json!({ + "proposal_id": l.proposal_id, + "action": action, + }) + }) + .collect(); + serde_json::json!({ + "utxo_ref": s.utxo_ref, + "owner_kind": owner_kind, + "owner_pkh_hex": owner_pkh, + "staked_amount": s.datum.staked_amount, + "gov_token_quantity": s.gov_token_quantity, + "lovelace": s.lovelace, + "delegated_to": delegated, + "locked_by": locks, + }) } #[tool(tool_box)] @@ -1279,7 +1611,7 @@ impl ServerHandler for WalletService { ServerInfo { capabilities: ServerCapabilities::builder().enable_tools().build(), instructions: Some( - "aldabra — Cardano lite wallet over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip) — for inspecting the chain at addresses/txs/pools beyond this wallet.".into(), + "aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Phase 1 read-only; voting/proposing land in subsequent phases.".into(), ), ..Default::default() }