feat(dao,mcp): proposal_retract_votes builder + dao_stake_retract_votes_unsigned

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 `registry.example.com:5000/aldabra/mcp@425c981` with
StakeRedeemer::RetractVotes and ProposalRedeemer::UnlockStake already
landed; this just wires the builder + MCP tool.
This commit is contained in:
Sulkta 2026-05-08 13:24:24 -07:00
parent 425c981682
commit cbf69161ab
3 changed files with 1117 additions and 1 deletions

View file

@ -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<String, String> {
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<DaoWalletUtxo> = {
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<String>,
/// 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/<name>.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/<name>.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()
}