From 1b9968cf3bed8c54788570deba43daea59805ada Mon Sep 17 00:00:00 2001 From: Kayos Date: Fri, 8 May 2026 13:24:24 -0700 Subject: [PATCH] feat(dao,mcp): proposal_retract_votes builder + dao_stake_retract_votes_unsigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the destroy-with-locks gap: a stake that voted/created/cosigned a proposal can now drop those locks once the proposal resolves (or once voter cooldown elapses), letting it become destroyable via dao_stake_destroy_unsigned. Tx shape mirrors proposal_vote: stake input (RetractVotes redeemer) + proposal input (UnlockStake redeemer) + wallet funding, two reference scripts, two outputs. Mode auto-derived from proposal status: - Finished → RemoveAllLocks (drop Created/Cosigned/Voted for id) - any other → RemoveVoterLockOnly (drop only past-cooldown Voted) When proposal is VotingReady AND tx-validity sits inside the voting window, also subtracts stake.staked_amount from proposal.votes[voted_tag] (matches the proposal validator's `shouldUpdateVotes` path at Agora/Proposal/Scripts.hs:606). Pre-flights every validator check: ownership/delegation, at least one lock for proposal_id (proposal validator's pisIrrelevant), Voted-lock cooldown (Stake/Redeemers.hs:354), and rejects the VotingReady-no-Voted edge case where the validator's "Votes changed" assertion would fail. Eight unit tests covering: Finished-drops-all, VotingReady-window- subtracts, no-locks-for-proposal rejection, cooldown-not-elapsed rejection, no-Voted-in-window rejection, voter-not-owner-or-delegate rejection, Locked-after-window-drops-Voted-only. Validator is from existing `lucy-registry:5000/aldabra/mcp@0c79231` with StakeRedeemer::RetractVotes and ProposalRedeemer::UnlockStake already landed; this just wires the builder + MCP tool. --- crates/aldabra-dao/src/builder/mod.rs | 1 + .../src/builder/proposal_retract_votes.rs | 896 ++++++++++++++++++ crates/aldabra-mcp/src/tools.rs | 221 ++++- 3 files changed, 1117 insertions(+), 1 deletion(-) create mode 100644 crates/aldabra-dao/src/builder/proposal_retract_votes.rs diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index 2f1996b..c0e8e2c 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -21,4 +21,5 @@ pub mod proposal_create; pub mod proposal_vote; pub mod proposal_cosign; pub mod proposal_advance; +pub mod proposal_retract_votes; pub mod stake_destroy; diff --git a/crates/aldabra-dao/src/builder/proposal_retract_votes.rs b/crates/aldabra-dao/src/builder/proposal_retract_votes.rs new file mode 100644 index 0000000..2bb2ebb --- /dev/null +++ b/crates/aldabra-dao/src/builder/proposal_retract_votes.rs @@ -0,0 +1,896 @@ +//! Build a `dao_proposal_retract_votes` transaction. +//! +//! Retract one stake's locks for a given proposal. Pairs with the proposal +//! being spent under `UnlockStake`. Two distinct effects depending on the +//! proposal's current status: +//! +//! - **Proposal in `VotingReady` (and within voting window)**: removes the +//! stake's `Voted` lock for that proposal AND subtracts the stake's +//! `staked_amount` from `proposal.votes[voted_tag]`. Stake's +//! `Created`/`Cosigned` locks for that proposal are kept. +//! - **Proposal in `Finished`**: removes ALL locks for that proposal_id +//! (Voted + Created + Cosigned). Proposal datum is unchanged. This is the +//! path that finally lets a stake become unlocked + destroyable after a +//! proposal has resolved. +//! - **Proposal in any other status / outside voting window**: removes only +//! `Voted` locks, AND only if the lock's cooldown has elapsed +//! (`createdAt + minStakeVotingTime ≤ tx_lower_ms`). Proposal datum is +//! unchanged. Useful for unlocking a stake on a `Locked` proposal whose +//! voting closed but isn't fully `Finished` yet. +//! +//! ## Tx shape +//! +//! - **Inputs**: +//! - The voter's stake UTxO (Plutus spend, redeemer = `RetractVotes`). +//! - The target proposal UTxO (Plutus spend, redeemer = `UnlockStake`). +//! - One ada-only wallet UTxO for funding. +//! - **Collateral input**: separate ada-only ≥5 ADA wallet UTxO. +//! - **Reference inputs**: +//! - Stake validator script. +//! - Proposal validator script. +//! - **No mints**. +//! - **Outputs**: +//! - New stake UTxO at `stakes_addr`. Datum = old StakeDatum with +//! `locked_by` filtered per the rules above. StakeST + gov-token qty +//! preserved. +//! - New proposal UTxO at `proposal_addr`. Datum is either: +//! - votes-mutated copy of input datum (only if VotingReady + in +//! voting window — matches `shouldUpdateVotes` in `Agora/Proposal/ +//! Scripts.hs:601-611`), +//! - or bit-identical copy of input datum (every other case). +//! ProposalST preserved. +//! - Wallet change. +//! +//! ## What the validator enforces (must match) +//! +//! From `Agora/Stake/Redeemers.hs:326` `pretractVote`: +//! +//! 1. ProposalContext is `PSpendProposal proposal UnlockStake currentTime` +//! — i.e. the same tx must spend a proposal under UnlockStake. +//! 2. Owner OR delegatee signs. +//! 3. Output stake datum equals input with only `locked_by` mutated to the +//! filtered list. +//! 4. Filter rule (per `premoveLocks` at `Stake/Redeemers.hs:284`): +//! - Voted lock for proposal_id: removed iff +//! `(mode = RemoveAllLocks)` OR +//! `(createdAt + minStakeVotingTime ≤ lowerBound)`. +//! If not in either case, validator errors. +//! - Created/Cosigned lock for proposal_id: removed iff +//! `mode = RemoveAllLocks`. +//! - Mode = `RemoveAllLocks` if proposal is `Finished`, else +//! `RemoveVoterLockOnly`. +//! +//! From `Agora/Proposal/Scripts.hs:569` `PUnlockStake` branch: +//! +//! 5. Every spent stake input must have `locked_by` containing at least one +//! lock for this proposal_id (i.e. `pgetStakeRoles` must NOT return +//! `PIrrelevant`). Pre-flighted client-side. +//! 6. If `currentStatus == VotingReady && tx-validity inside voting period`: +//! proposal output votes = `pretractVotes` over input stakes (subtract +//! each voter stake's amount from its voted tag). Other proposal fields +//! bit-identical. +//! 7. Otherwise: proposal output = bit-identical copy of input. +//! 8. Validity range width ≤ `votingTimeRangeMaxWidth`. +//! +//! ## What's NOT in v1 +//! +//! - **Multi-stake retraction** — `pretractVotes` over multiple stakes is +//! what the proposal validator supports; v1 supports a single stake to +//! match the rest of our builder family. +//! - **Selecting which lock to retract when a stake has multiple voted +//! locks for the same proposal_id** — that shouldn't happen (the voter +//! path prevents double-vote), but if it ever does, we retract all of +//! them together. + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction}; + +use crate::agora::proposal::{ + ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes, +}; +use crate::agora::stake::{ + Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, +}; +use crate::config::{DaoConfig, DaoNetwork}; +use crate::error::{DaoError, DaoResult}; + +use super::proposal_create::{ + parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo, + MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as RETRACT_SPEND_EX_UNITS, + SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS, +}; +use super::proposal_vote::ProposalUtxoIn; + +/// Wallet-change min-UTxO floor. Same value used in proposal_vote. +const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; + +/// Args bundle for [`build_unsigned_proposal_retract_votes`]. +#[derive(Debug, Clone)] +pub struct ProposalRetractVotesArgs { + pub cfg: DaoConfig, + pub stake_in: StakeUtxoIn, + pub proposal: ProposalUtxoIn, + /// Voter's payment-credential hash (28 bytes). Must equal stake's + /// owner pkh OR stake's delegated_to pkh. + pub voter_pkh: Vec, + /// Voter wallet's bech32 address (for change). + pub change_address: String, + /// Spendable wallet UTxOs. + pub wallet_utxos: Vec, + /// Current chain tip slot. Sets `valid_from_slot(tip_slot)` and + /// `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`. + pub tip_slot: u64, + /// POSIX-ms equivalent of the validity range's LOWER bound (= tip_slot + /// converted to ms via the Shelley genesis epoch). Used for cooldown + /// preflight on Voted locks: `createdAt + minStakeVotingTime ≤ this`. + /// Caller is responsible for the slot↔ms conversion. + pub validity_lower_ms: i64, + /// Reference UTxO citing the stake validator script. + pub stake_validator_ref: ReferenceUtxo, + /// Reference UTxO citing the proposal validator script. + pub proposal_validator_ref: ReferenceUtxo, + /// Estimated total fee. Caller-supplied for v1. + pub fee_lovelace: u64, +} + +/// What [`build_unsigned_proposal_retract_votes`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedProposalRetractVotes { + /// CBOR-hex of the unsigned tx body. + pub tx_cbor_hex: String, + /// Blake2b-256 hash of the tx body. + pub tx_hash_hex: String, + /// The proposal_id whose locks were retracted. + pub proposal_id: i64, + /// Number of locks dropped from the stake. + pub locks_removed: usize, + /// Vote weight subtracted from the proposal's votes map (0 if the + /// proposal datum was unchanged — either no Voted lock or proposal + /// outside its voting window). + pub vote_weight_retracted: i64, + /// Whether the new stake datum has any locks remaining for this + /// proposal_id (true means stake is still partially locked by this + /// proposal — e.g. still has Created lock after retracting Voted). + pub stake_still_locked_by_this_proposal: bool, + /// Human-readable summary. + pub summary: String, +} + +/// Build the unsigned proposal-retract-votes tx. +pub fn build_unsigned_proposal_retract_votes( + args: ProposalRetractVotesArgs, +) -> DaoResult { + let proposal_id = args.proposal.datum.proposal_id; + + // ---- preflight checks ------------------------------------------------ + + // Voter must be owner or delegatee. + let voter_is_owner = matches!( + &args.stake_in.datum.owner, + Credential::PubKey(h) if *h == args.voter_pkh + ); + let voter_is_delegate = match &args.stake_in.datum.delegated_to { + Some(Credential::PubKey(h)) => *h == args.voter_pkh, + _ => false, + }; + if !voter_is_owner && !voter_is_delegate { + return Err(DaoError::State( + "voter pkh is neither stake owner nor delegatee — cannot retract with this stake".into(), + )); + } + + // Stake must have at least one lock for this proposal_id (else the + // proposal validator's `pisIrrelevant` check rejects). + let locks_for_proposal: Vec<&ProposalLock> = args + .stake_in + .datum + .locked_by + .iter() + .filter(|l| l.proposal_id == proposal_id) + .collect(); + if locks_for_proposal.is_empty() { + return Err(DaoError::State(format!( + "stake has no locks for proposal #{} — nothing to retract", + proposal_id + ))); + } + + // Decide the retract mode the validator will see. + let mode = if args.proposal.datum.status == ProposalStatus::Finished { + RetractMode::RemoveAllLocks + } else { + RetractMode::RemoveVoterLockOnly + }; + + // Determine voting-window state. Matters because the proposal validator + // only allows votes-mutation if `status == VotingReady && tx_validity + // inside [start+draft, start+draft+voting]`. + let voting_start_ms = + args.proposal.datum.starting_time + args.proposal.datum.timing_config.draft_time; + let voting_end_ms = voting_start_ms + args.proposal.datum.timing_config.voting_time; + let tx_lower_ms = args.validity_lower_ms; + let tx_upper_ms = tx_lower_ms + + (VALIDITY_RANGE_SLOTS as i64) * 1000; + let in_voting_window = tx_lower_ms >= voting_start_ms && tx_upper_ms <= voting_end_ms; + let proposal_datum_will_change = args.proposal.datum.status == ProposalStatus::VotingReady + && in_voting_window; + + // Voter cooldown preflight (only applies when removing Voted locks + // outside the RemoveAllLocks path — i.e. proposal is NOT Finished). + // Per `premoveLocks`, a Voted lock must satisfy + // `createdAt + minStakeVotingTime ≤ lowerBound` to be removable. + let unlock_cooldown = args.proposal.datum.timing_config.min_stake_voting_time; + let mut voted_lock_to_retract: Option<&ProposalLock> = None; + for lock in &locks_for_proposal { + if let ProposalAction::Voted { posix_time, .. } = &lock.action { + if matches!(mode, RetractMode::RemoveVoterLockOnly) { + let ready_at = posix_time + .checked_add(unlock_cooldown) + .ok_or_else(|| DaoError::State("cooldown overflow".into()))?; + if tx_lower_ms < ready_at { + return Err(DaoError::State(format!( + "Voted lock for proposal #{} not past cooldown yet: \ + tx_lower_ms={} < createdAt({})+minStakeVotingTime({})={}", + proposal_id, + tx_lower_ms, + posix_time, + unlock_cooldown, + ready_at + ))); + } + } + voted_lock_to_retract = Some(lock); + } + } + + // ---- compute new datums --------------------------------------------- + + // Filter `locked_by` per the validator's `premoveLocks` rule. + let mut new_locks: Vec = Vec::with_capacity(args.stake_in.datum.locked_by.len()); + let mut locks_removed = 0usize; + for lock in &args.stake_in.datum.locked_by { + let keep = if lock.proposal_id != proposal_id { + // Different proposal — keep. + true + } else { + match (&mode, &lock.action) { + // RemoveAll: drop everything for this proposal_id. + (RetractMode::RemoveAllLocks, _) => false, + // RemoveVoterOnly: drop only Voted locks. + (RetractMode::RemoveVoterLockOnly, ProposalAction::Voted { .. }) => false, + // Created/Cosigned in voter-only mode: keep. + (RetractMode::RemoveVoterLockOnly, _) => true, + } + }; + if keep { + new_locks.push(lock.clone()); + } else { + locks_removed += 1; + } + } + let new_stake = StakeDatum { + staked_amount: args.stake_in.datum.staked_amount, + owner: args.stake_in.datum.owner.clone(), + delegated_to: args.stake_in.datum.delegated_to.clone(), + locked_by: new_locks.clone(), + }; + + // Build the new proposal datum. Two paths: + // - VotingReady + in voting window AND we have a Voted lock to retract: + // proposal.votes[voted_tag] -= stake.staked_amount. + // - Otherwise: bit-identical copy of input datum. + let mut vote_weight_retracted: i64 = 0; + let new_proposal_datum = if proposal_datum_will_change { + if let Some(ProposalLock { + action: ProposalAction::Voted { result_tag, .. }, + .. + }) = voted_lock_to_retract.cloned() + { + // Subtract this stake's vote weight from the matching tag. + let mut new_votes_inner = args.proposal.datum.votes.0.clone(); + let mut found = false; + for (k, v) in new_votes_inner.iter_mut() { + if *k == result_tag { + let stake_amt = args.stake_in.datum.staked_amount; + *v = v.checked_sub(stake_amt).ok_or_else(|| { + DaoError::State(format!( + "vote retract underflow: votes[{result_tag}]={} - staked_amount={}", + v, stake_amt + )) + })?; + vote_weight_retracted = stake_amt; + found = true; + break; + } + } + if !found { + return Err(DaoError::State(format!( + "voted result_tag {} not present in proposal.votes — datum corruption?", + result_tag + ))); + } + ProposalDatum { + proposal_id, + effects_raw: args.proposal.datum.effects_raw.clone(), + status: args.proposal.datum.status, + cosigners: args.proposal.datum.cosigners.clone(), + thresholds: args.proposal.datum.thresholds.clone(), + votes: ProposalVotes(new_votes_inner), + timing_config: args.proposal.datum.timing_config.clone(), + starting_time: args.proposal.datum.starting_time, + } + } else { + // VotingReady + window-open but no Voted lock to retract — datum + // must be unchanged per `shouldUpdateVotes` requiring votes to + // ACTUALLY change ("Votes changed" trace). We fall through to + // unchanged-datum path. + args.proposal.datum.clone() + } + } else { + args.proposal.datum.clone() + }; + + // Re-evaluate whether the proposal datum is bit-identical or mutated. + // If bit-identical, the validator takes the "Proposal unchanged" branch. + let proposal_datum_actually_changed = vote_weight_retracted != 0; + if proposal_datum_will_change && !proposal_datum_actually_changed { + // VotingReady + in-window but no votes to retract — validator + // still requires the votes-mutation branch (shouldUpdateVotes=true) + // and "Votes changed" assertion will fail. Bail out client-side. + return Err(DaoError::State(format!( + "proposal #{} is VotingReady + in voting window but stake has no Voted lock — \ + validator requires votes to change in this branch. Wait until proposal \ + advances out of VotingReady (or window closes) before retracting Created/Cosigned.", + proposal_id + ))); + } + + let new_stake_datum_pd = new_stake.to_plutus_data()?; + let new_proposal_datum_pd = new_proposal_datum.to_plutus_data()?; + let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd) + .map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?; + let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd) + .map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?; + + // ---- redeemers ------------------------------------------------------- + + let stake_spend_redeemer_cbor = + minicbor::to_vec(&StakeRedeemer::RetractVotes.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; + let proposal_spend_redeemer_cbor = + minicbor::to_vec(&ProposalRedeemer::UnlockStake.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?; + + // ---- pick funding + collateral --------------------------------------- + + let mut ada_only: Vec = args + .wallet_utxos + .iter() + .filter(|u| u.is_ada_only()) + .cloned() + .collect(); + ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace)); + + let collateral = ada_only + .iter() + .rev() + .find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE) + .ok_or_else(|| { + DaoError::State(format!( + "no ada-only wallet UTxO ≥ {} lovelace for collateral", + MIN_COLLATERAL_LOVELACE + )) + })? + .clone(); + let funding = ada_only + .iter() + .find(|u| { + !(u.tx_hash_hex == collateral.tx_hash_hex + && u.output_index == collateral.output_index) + }) + .cloned() + .ok_or_else(|| { + DaoError::State( + "need a SECOND ada-only wallet UTxO to fund the spend (separate from collateral)" + .into(), + ) + })?; + + // ---- balance + change ------------------------------------------------ + + let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE); + let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE); + let total_in = args + .stake_in + .lovelace + .checked_add(args.proposal.lovelace) + .and_then(|x| x.checked_add(funding.lovelace)) + .ok_or_else(|| DaoError::State("input lovelace overflow".into()))?; + let total_out = new_stake_lovelace + .checked_add(new_proposal_lovelace) + .and_then(|x| x.checked_add(args.fee_lovelace)) + .ok_or_else(|| DaoError::State("output lovelace overflow".into()))?; + let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| { + DaoError::State(format!( + "insufficient input: total_in={total_in} need={total_out}" + )) + })?; + if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE { + return Err(DaoError::State(format!( + "change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE})" + ))); + } + + // ---- assemble pallas StagingTransaction ------------------------------ + + let stakes_addr = parse_address(&args.cfg.stakes_addr)?; + let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| { + DaoError::Config( + "proposal_addr not set on DaoConfig — register or discover_scripts first".into(), + ) + })?)?; + let change_addr = parse_address(&args.change_address)?; + + let stake_input = Input::new( + parse_tx_hash(&args.stake_in.tx_hash_hex)?, + args.stake_in.output_index as u64, + ); + let proposal_input = Input::new( + parse_tx_hash(&args.proposal.tx_hash_hex)?, + args.proposal.output_index as u64, + ); + let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.output_index as u64); + let collateral_input = Input::new( + parse_tx_hash(&collateral.tx_hash_hex)?, + collateral.output_index as u64, + ); + let stake_validator_ref_input = Input::new( + parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?, + args.stake_validator_ref.output_index as u64, + ); + let proposal_validator_ref_input = Input::new( + parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?, + args.proposal_validator_ref.output_index as u64, + ); + + let stake_st_policy_hash = parse_script_hash(args.cfg.stake_st_policy.as_deref().ok_or_else(|| { + DaoError::Config("stake_st_policy not set on DaoConfig".into()) + })?)?; + let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex) + .map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?; + let proposal_st_policy_hash = parse_script_hash(args.cfg.proposal_st_policy.as_deref().ok_or_else(|| { + DaoError::Config("proposal_st_policy not set on DaoConfig".into()) + })?)?; + let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex) + .map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?; + let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?; + let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex) + .map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?; + + let network_id = match args.cfg.network { + DaoNetwork::Mainnet => 1u8, + DaoNetwork::Preprod | DaoNetwork::Preview => 0u8, + }; + + let new_stake_output = Output::new(stakes_addr, new_stake_lovelace) + .set_inline_datum(new_stake_datum_cbor.clone()) + .add_asset(stake_st_policy_hash, stake_st_asset_name, 1) + .and_then(|o| o.add_asset( + gov_token_policy_hash, + gov_token_name_bytes, + args.stake_in.gov_token_qty, + )) + .map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?; + + let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace) + .set_inline_datum(new_proposal_datum_cbor.clone()) + .add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1) + .map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?; + + let mut staging = StagingTransaction::new(); + staging = staging.input(stake_input.clone()); + staging = staging.input(proposal_input.clone()); + staging = staging.input(funding_input.clone()); + staging = staging.collateral_input(collateral_input); + staging = staging.reference_input(stake_validator_ref_input); + staging = staging.reference_input(proposal_validator_ref_input); + staging = staging.output(new_stake_output); + staging = staging.output(new_proposal_output); + + if change_lovelace > 0 { + let mut change_output = Output::new(change_addr, change_lovelace); + for (policy_hex, name_hex, qty) in &funding.assets { + let policy = parse_script_hash(policy_hex)?; + let name = hex::decode(name_hex) + .map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?; + change_output = change_output + .add_asset(policy, name, *qty) + .map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?; + } + staging = staging.output(change_output); + } + + staging = staging.add_spend_redeemer( + stake_input, + stake_spend_redeemer_cbor, + Some(RETRACT_SPEND_EX_UNITS), + ); + staging = staging.add_spend_redeemer( + proposal_input, + proposal_spend_redeemer_cbor, + Some(RETRACT_SPEND_EX_UNITS), + ); + + // Validity range. For Finished proposals, no window constraint applies + // (proposal datum is unchanged, no `inVotingPeriod` check). For + // VotingReady + window-open, we must stay within [voting_start, + // voting_end] for the votes-mutation path. For other statuses, no + // constraint. We use a wide-by-default range and the caller (MCP) can + // narrow via tip-slot picking if needed. + staging = staging.valid_from_slot(args.tip_slot); + staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS); + + // Disclosed signer: voter pkh. + let voter_pkh_arr: [u8; 28] = args + .voter_pkh + .as_slice() + .try_into() + .map_err(|_| DaoError::Datum(format!( + "voter_pkh must be 28 bytes, got {}", + args.voter_pkh.len() + )))?; + staging = staging.disclosed_signer(Hash::<28>::from(voter_pkh_arr)); + + staging = staging.fee(args.fee_lovelace).network_id(network_id); + + // V2 cost model — same fix as proposal_create / proposal_advance / etc. + staging = staging.language_view( + ScriptKind::PlutusV2, + aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(), + ); + + let built = staging + .build_conway_raw() + .map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?; + + let tx_cbor_hex = hex::encode(&built.tx_bytes.0); + let tx_hash_hex = hex::encode(built.tx_hash.0); + + let stake_still_locked_by_this_proposal = + new_locks.iter().any(|l| l.proposal_id == proposal_id); + + let summary = format!( + "dao_proposal_retract_votes_unsigned: dao={} proposal_id={} mode={:?} \ + locks_removed={} vote_weight_retracted={} stake_still_locked={} fee={}", + args.cfg.name, + proposal_id, + mode, + locks_removed, + vote_weight_retracted, + stake_still_locked_by_this_proposal, + args.fee_lovelace, + ); + + Ok(UnsignedProposalRetractVotes { + tx_cbor_hex, + tx_hash_hex, + proposal_id, + locks_removed, + vote_weight_retracted, + stake_still_locked_by_this_proposal, + summary, + }) +} + +/// Mirrors Agora's `PRemoveLocksMode`. Selected by inspecting the proposal's +/// status — not a caller-supplied arg, since picking the wrong mode would +/// just get rejected by the validator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RetractMode { + /// Drop only `Voted` locks for the proposal_id (and only those past + /// cooldown). Kept: `Created`, `Cosigned`. Selected when proposal is + /// not `Finished`. + RemoveVoterLockOnly, + /// Drop ALL locks for the proposal_id. Selected when proposal is + /// `Finished` — the only path that lets a stake get fully unlocked + /// after a proposal it created/cosigned has resolved. + RemoveAllLocks, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agora::plutus_data::constr; + use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; + use crate::config::ScriptRefs; + + fn voter_pkh_bytes() -> Vec { + hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() + } + + fn sample_proposal_datum_finished() -> ProposalDatum { + ProposalDatum { + proposal_id: 7, + effects_raw: constr(0, vec![]), + status: ProposalStatus::Finished, + cosigners: vec![Credential::PubKey(voter_pkh_bytes())], + thresholds: ProposalThresholds { + execute: 20, + create: 100, + to_voting: 100, + vote: 1, + cosign: 1, + }, + votes: ProposalVotes(vec![(0, 250), (1, 0)]), + timing_config: ProposalTimingConfig { + draft_time: 7 * 86400 * 1000, + voting_time: 7 * 86400 * 1000, + locking_time: 48 * 3600 * 1000, + executing_time: 24 * 3600 * 1000, + min_stake_voting_time: 60 * 60 * 1000, + voting_time_range_max_width: 30 * 60 * 1000, + }, + starting_time: 1_780_000_000_000, + } + } + + fn sample_proposal_datum_voting_ready() -> ProposalDatum { + let mut d = sample_proposal_datum_finished(); + d.proposal_id = 5; + d.status = ProposalStatus::VotingReady; + d + } + + fn sample_args( + proposal_datum: ProposalDatum, + stake_locks: Vec, + validity_lower_ms: i64, + ) -> ProposalRetractVotesArgs { + ProposalRetractVotesArgs { + cfg: 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: DaoNetwork::Mainnet, + proposal_addr: Some( + "addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(), + ), + stake_st_policy: Some( + "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), + ), + proposal_st_policy: Some( + "9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(), + ), + script_refs: ScriptRefs::default(), + }, + stake_in: StakeUtxoIn { + tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(), + output_index: 1, + lovelace: 5_000_000, + gov_token_qty: 250, + stake_st_asset_name_hex: + "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(), + datum: StakeDatum { + staked_amount: 250, + owner: Credential::PubKey(voter_pkh_bytes()), + delegated_to: None, + locked_by: stake_locks, + }, + }, + proposal: ProposalUtxoIn { + tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(), + output_index: 0, + lovelace: 2_000_000, + proposal_st_asset_name_hex: "".into(), + datum: proposal_datum, + }, + voter_pkh: voter_pkh_bytes(), + change_address: + "addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6" + .into(), + wallet_utxos: vec![ + WalletUtxo { + tx_hash_hex: "00".repeat(31) + "01", + output_index: 0, + lovelace: 10_000_000, + assets: vec![], + }, + WalletUtxo { + tx_hash_hex: "00".repeat(31) + "02", + output_index: 0, + lovelace: 6_000_000, + assets: vec![], + }, + ], + tip_slot: 180_062_536, + validity_lower_ms, + stake_validator_ref: ReferenceUtxo { + tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(), + output_index: 2, + }, + proposal_validator_ref: ReferenceUtxo { + tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(), + output_index: 1, + }, + fee_lovelace: 2_500_000, + } + } + + #[test] + fn finished_proposal_drops_all_locks_for_id() { + // Stake has Created lock for #7 (Finished) and Voted lock for #7, + // plus Created for #99 (other proposal). Retract on #7 should drop + // both #7 locks, keep #99. + let locks = vec![ + ProposalLock { + proposal_id: 7, + action: ProposalAction::Voted { + result_tag: 0, + posix_time: 1_780_000_000_000, + }, + }, + ProposalLock { + proposal_id: 7, + action: ProposalAction::Created, + }, + ProposalLock { + proposal_id: 99, + action: ProposalAction::Created, + }, + ]; + let args = sample_args( + sample_proposal_datum_finished(), + locks, + 1_780_010_000_000, + ); + let unsigned = build_unsigned_proposal_retract_votes(args).expect("build"); + assert_eq!(unsigned.proposal_id, 7); + assert_eq!(unsigned.locks_removed, 2); + // Finished + datum unchanged → vote_weight_retracted reports 0 even + // if a Voted lock was dropped, because we don't mutate the proposal. + assert_eq!(unsigned.vote_weight_retracted, 0); + assert!(!unsigned.stake_still_locked_by_this_proposal); + } + + #[test] + fn voting_ready_in_window_subtracts_vote_weight() { + // VotingReady proposal #5 with starting_time + draft = voting_start. + // Pick validity_lower in the voting window. Stake has Voted lock on + // #5 for tag 0. Retract should subtract 250 from votes[0] and + // remove ONLY the Voted lock (Created locks for #5 stay). + let proposal = sample_proposal_datum_voting_ready(); + let voting_start = proposal.starting_time + proposal.timing_config.draft_time; + let validity_lower = voting_start + 60_000; // 1 min into voting window + let locks = vec![ + ProposalLock { + proposal_id: 5, + action: ProposalAction::Voted { + result_tag: 0, + posix_time: voting_start - 30_000, + }, + }, + ProposalLock { + proposal_id: 5, + action: ProposalAction::Created, + }, + ]; + let args = sample_args(proposal, locks, validity_lower); + let unsigned = build_unsigned_proposal_retract_votes(args).expect("build"); + assert_eq!(unsigned.proposal_id, 5); + assert_eq!(unsigned.locks_removed, 1); + assert_eq!(unsigned.vote_weight_retracted, 250); + assert!(unsigned.stake_still_locked_by_this_proposal); + } + + #[test] + fn rejects_no_locks_for_proposal() { + let locks = vec![ProposalLock { + proposal_id: 99, + action: ProposalAction::Created, + }]; + let args = sample_args( + sample_proposal_datum_finished(), + locks, + 1_780_010_000_000, + ); + let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); + assert!(err.to_string().contains("no locks for proposal")); + } + + #[test] + fn rejects_voted_lock_in_cooldown() { + // VotingReady but voter cooldown not yet elapsed. Locked status not + // Finished → RemoveVoterLockOnly mode → cooldown applies. + let mut proposal = sample_proposal_datum_voting_ready(); + proposal.status = ProposalStatus::Locked; // voting closed; not Finished + let proposal_id = proposal.proposal_id; + let voted_at = 1_780_000_500_000i64; + let cooldown_ms = proposal.timing_config.min_stake_voting_time; + let locks = vec![ProposalLock { + proposal_id, + action: ProposalAction::Voted { + result_tag: 0, + posix_time: voted_at, + }, + }]; + let validity_lower = voted_at + cooldown_ms - 1; // 1 ms shy of cooldown + let args = sample_args(proposal, locks, validity_lower); + let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); + assert!(err.to_string().contains("not past cooldown")); + } + + #[test] + fn rejects_voting_ready_in_window_without_voted_lock() { + // VotingReady + in-window but stake only has Created lock — the + // validator's "Votes changed" assertion would fail. Builder must + // bail client-side. + let proposal = sample_proposal_datum_voting_ready(); + let voting_start = proposal.starting_time + proposal.timing_config.draft_time; + let validity_lower = voting_start + 60_000; + let locks = vec![ProposalLock { + proposal_id: 5, + action: ProposalAction::Created, + }]; + let args = sample_args(proposal, locks, validity_lower); + let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); + assert!( + err.to_string().contains("validator requires votes to change"), + "unexpected err: {err}" + ); + } + + #[test] + fn rejects_voter_neither_owner_nor_delegate() { + let locks = vec![ProposalLock { + proposal_id: 7, + action: ProposalAction::Created, + }]; + let mut args = sample_args( + sample_proposal_datum_finished(), + locks, + 1_780_010_000_000, + ); + args.voter_pkh = vec![0xee; 28]; + let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); + assert!(err.to_string().contains("neither stake owner nor delegatee")); + } + + #[test] + fn locked_status_after_voting_window_drops_voted_lock_only() { + // Locked status → RemoveVoterLockOnly. Cooldown elapsed → + // Voted lock dropped, Created kept. Proposal datum unchanged + // (not VotingReady → shouldUpdateVotes = false). + let mut proposal = sample_proposal_datum_voting_ready(); + proposal.status = ProposalStatus::Locked; + let proposal_id = proposal.proposal_id; + let voted_at = proposal.starting_time + proposal.timing_config.draft_time; + let cooldown_ms = proposal.timing_config.min_stake_voting_time; + let validity_lower = voted_at + cooldown_ms + 1_000; + let locks = vec![ + ProposalLock { + proposal_id, + action: ProposalAction::Voted { + result_tag: 1, + posix_time: voted_at, + }, + }, + ProposalLock { + proposal_id, + action: ProposalAction::Created, + }, + ]; + let args = sample_args(proposal, locks, validity_lower); + let unsigned = build_unsigned_proposal_retract_votes(args).expect("build"); + assert_eq!(unsigned.locks_removed, 1); + assert_eq!(unsigned.vote_weight_retracted, 0); // proposal datum unchanged + assert!(unsigned.stake_still_locked_by_this_proposal); // Created still there + } +} diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 356225b..1de9609 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -43,6 +43,9 @@ use aldabra_dao::builder::proposal_cosign::{ use aldabra_dao::builder::proposal_advance::{ build_unsigned_proposal_advance, AdvanceTransition, CosignerStakeRef, ProposalAdvanceArgs, }; +use aldabra_dao::builder::proposal_retract_votes::{ + build_unsigned_proposal_retract_votes, ProposalRetractVotesArgs, +}; use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs}; use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; use aldabra_dao::discovery::{ @@ -3231,6 +3234,211 @@ impl WalletService { .to_string()) } + #[tool( + name = "dao_stake_retract_votes_unsigned", + description = "Build (but DO NOT submit) an unsigned retract-votes tx. Spends voter's stake (RetractVotes redeemer) + the proposal UTxO (UnlockStake redeemer). Removes the stake's locks for this proposal. Mode is auto-derived from the proposal's status: Finished → ALL locks for this proposal_id are dropped (including Created/Cosigned, finally letting the stake become destroyable); any other status → only Voted locks are dropped, AND only if past their cooldown (`createdAt + minStakeVotingTime ≤ tx_lower_ms`). When the proposal is VotingReady AND tx-validity sits inside the voting window, also subtracts stake.staked_amount from proposal.votes[voted_tag]. Pre-flights: voter is owner-or-delegatee, stake has at least one lock for this proposal_id, Voted-lock cooldown elapsed (when applicable), and rejects the VotingReady-no-Voted-lock case where the validator's `Votes changed` assertion would fail. Args: dao (optional — defaults to active), proposal_id (i64), fee_lovelace (~2_500_000 reasonable). Returns CBOR-hex of the unsigned tx body. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx." + )] + async fn dao_stake_retract_votes_unsigned( + &self, + #[tool(aggr)] DaoStakeRetractVotesArgs { + dao, + proposal_id, + fee_lovelace, + }: DaoStakeRetractVotesArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + + let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| { + "DaoConfig.proposal_addr missing — register or discover_scripts first".to_string() + })?; + + // Find the proposal. + let proposals = self + .inner + .dao_reader + .list_proposals(&cfg) + .await + .map_err(|e| e.to_string())?; + let target = proposals + .into_iter() + .find(|p| p.datum.proposal_id == proposal_id) + .ok_or_else(|| { + format!("no proposal with proposal_id={} found at {}", proposal_id, proposal_addr) + })?; + let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?; + let prop_lovelace = target.lovelace; + let prop_st_name_hex = target.proposal_st_asset_name_hex; + let prop_datum = target.datum; + + // Find the voter's stake. + let voter_pkh = self.wallet_pkh()?; + let stakes = self + .inner + .dao_reader + .list_stakes(&cfg) + .await + .map_err(|e| e.to_string())?; + let my_stake = stakes + .into_iter() + .find(|s| match &s.datum.owner { + aldabra_dao::agora::stake::Credential::PubKey(h) => h == &voter_pkh, + _ => false, + }) + .ok_or_else(|| { + format!( + "no stake at stakes_addr owned by this wallet's pkh {} — \ + wallet must hold a registered stake to retract", + hex::encode(&voter_pkh) + ) + })?; + let (stake_tx, stake_idx) = parse_utxo_ref(&my_stake.utxo_ref)?; + + // StakeST asset name from chain. + let stake_utxos_raw = self + .inner + .chain + .get_utxos(&cfg.stakes_addr) + .await + .map_err(|e| format!("koios get stake utxos: {e}"))?; + let stake_utxo_raw = stake_utxos_raw + .into_iter() + .find(|u| u.tx_hash == stake_tx && u.output_index == stake_idx) + .ok_or_else(|| format!("stake utxo {} no longer on chain", my_stake.utxo_ref))?; + let stake_st_asset_name_hex = stake_utxo_raw + .assets + .iter() + .find_map(|(k, _)| { + if k.len() < 56 { + return None; + } + let (p, n) = k.split_at(56); + if p == cfg.stake_st_policy.as_deref().unwrap_or("") { + Some(n.to_string()) + } else { + None + } + }) + .ok_or_else(|| "stake UTxO is missing StakeST token".to_string())?; + + // Chain tip slot + validity_lower_ms. + let tip_resp = self + .inner + .chain + .get_raw_json("tip", &[]) + .await + .map_err(|e| format!("koios tip: {e}"))?; + let tip: serde_json::Value = + serde_json::from_str(&tip_resp).map_err(|e| format!("tip parse: {e}"))?; + let tip_slot = tip + .as_array() + .and_then(|a| a.first()) + .and_then(|t| t.get("abs_slot")) + .and_then(|s| s.as_u64()) + .ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?; + let validity_lower_ms = slot_to_posix_ms(cfg.network, tip_slot)?; + + // Wallet utxos. + let wallet_utxos: Vec = { + let raw = self + .inner + .chain + .get_utxos(&self.inner.address) + .await + .map_err(|e| format!("koios get wallet utxos: {e}"))?; + let mut out = Vec::with_capacity(raw.len()); + for u in raw { + let mut assets = Vec::with_capacity(u.assets.len()); + for (k, q) in u.assets { + if k.len() < 56 { + return Err(format!( + "malformed asset key in wallet utxo {tx_hash}#{idx}: \ + {k:?} is {len} chars, need ≥ 56", + tx_hash = u.tx_hash, + idx = u.output_index, + len = k.len(), + )); + } + let (p, n) = k.split_at(56); + assets.push((p.to_string(), n.to_string(), q)); + } + out.push(DaoWalletUtxo { + tx_hash_hex: u.tx_hash, + output_index: u.output_index, + lovelace: u.lovelace, + assets, + }); + } + out + }; + + // Reference UTxOs — same pattern as vote. + let stake_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .stake_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first".to_string() + })?, + ) + .map_err(|e| e.to_string())?; + let proposal_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .proposal_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.proposal_validator missing — \ + run dao_discover_scripts first".to_string() + })?, + ) + .map_err(|e| e.to_string())?; + + let unsigned = build_unsigned_proposal_retract_votes(ProposalRetractVotesArgs { + cfg: cfg.clone(), + stake_in: StakeUtxoIn { + tx_hash_hex: stake_tx, + output_index: stake_idx, + lovelace: my_stake.lovelace, + gov_token_qty: my_stake.gov_token_quantity, + stake_st_asset_name_hex, + datum: my_stake.datum, + }, + proposal: ProposalUtxoIn { + tx_hash_hex: prop_tx, + output_index: prop_idx, + lovelace: prop_lovelace, + proposal_st_asset_name_hex: prop_st_name_hex, + datum: prop_datum, + }, + voter_pkh, + change_address: self.inner.address.clone(), + wallet_utxos, + tip_slot, + validity_lower_ms, + stake_validator_ref, + proposal_validator_ref, + fee_lovelace, + }) + .map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "dao": cfg.name, + "tx_cbor_hex": unsigned.tx_cbor_hex, + "tx_hash_hex": unsigned.tx_hash_hex, + "proposal_id": unsigned.proposal_id, + "locks_removed": unsigned.locks_removed, + "vote_weight_retracted": unsigned.vote_weight_retracted, + "stake_still_locked_by_this_proposal": unsigned.stake_still_locked_by_this_proposal, + "summary": unsigned.summary, + "next_step": "review tx_cbor_hex (decode + audit), then sign via wallet_sign_partial + submit via wallet_submit_signed_tx", + }) + .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)." @@ -3396,6 +3604,17 @@ pub struct DaoProposalCosignArgs { pub fee_lovelace: u64, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoStakeRetractVotesArgs { + /// Named DAO. Falls through to active if omitted. + #[serde(default)] + pub dao: Option, + /// Proposal id whose locks should be retracted from this stake. + pub proposal_id: i64, + /// Estimated total fee in lovelace. ~2_500_000 reasonable for v1. + pub fee_lovelace: u64, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DaoProposalVoteArgs { /// Named DAO. Falls through to active if omitted. @@ -3585,7 +3804,7 @@ impl ServerHandler for WalletService { ServerInfo { capabilities: ServerCapabilities::builder().enable_tools().build(), instructions: Some( - "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), Conway governance (wallet_vote_delegate to a DRep, wallet_drep_register / wallet_drep_deregister for becoming a DRep, wallet_drep_vote_cast for casting Yes/No/Abstain votes on governance actions). 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). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".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), Conway governance (wallet_vote_delegate to a DRep, wallet_drep_register / wallet_drep_deregister for becoming a DRep, wallet_drep_vote_cast for casting Yes/No/Abstain votes on governance actions). 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). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_retract_votes_unsigned (drop a stake's locks for a given proposal — Finished proposals drop ALL locks, others drop only past-cooldown Voted locks; pre-condition for stake destroy on a stake that voted), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(), ), ..Default::default() }