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:
parent
425c981682
commit
cbf69161ab
3 changed files with 1117 additions and 1 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue