feat(dao): proposal_advance state machine + stake_destroy + MCP tools

Phase 4c + 4d. Closes the DAO write-path arc (excluding GAT minting,
which is Phase 4c-bis since Sulkta has never executed a proposal).

## proposal_advance (Phase 4c)

State-machine builder with 5 transitions:
- Draft → VotingReady (cosigner threshold met, all cosigner stakes
  ref'd as txInfo.referenceInputs, sum staked_amount ≥ to_voting)
- Draft → Finished (drafting period elapsed without enough cosigners)
- VotingReady → Locked (winner outcome exists with votes ≥ execute,
  no tie)
- VotingReady → Finished (locking period elapsed without winner)
- Locked → Finished (executing period elapsed; for InfoOnly proposals)

Validator (PAdvanceProposal in Proposal/Scripts.hs:657) requires
output proposal datum equals input with ONLY status mutated. Builder
mirrors exactly. Per-transition preflights match validator gates.

Cosigner stake refs go in as txInfo.referenceInputs (not regular
inputs) per witnessStakes pattern (Proposal/Scripts.hs:366) — sum
of staked_amount is computed from the ref-input set.

GAT-minting Locked→Finished path (effected proposals) deferred to
4c-bis. The pmintGATs governor redeemer is a separate tx that fires
ONLY when the executing period is in window AND the winner outcome
has effects to mint GATs for. Sulkta's first proposal was InfoOnly
so this path never exercised on chain yet.

11 unit tests covering every transition + every preflight reject.

## stake_destroy (Phase 4d)

Burns StakeST token + returns gov-tokens to owner. From
Stake/Redeemers.hs pdestroy (~L432): owner signs (no delegatees),
all locks empty, no stake output at stakes_addr. From stakePolicy
burn branch (~L161): burntST quantity = -spentST.

Tx shape: spend stake (Destroy redeemer) + maybe a funding utxo +
collateral; mint -1 StakeST; one wallet output carrying gov-tokens
+ (stake.lovelace + funding - fee). Funding optional — stake's own
lovelace usually covers fees.

4 unit tests including the funding-optional path.

## MCP tools

dao_proposal_advance_unsigned auto-picks the right transition from
proposal status + chain tip vs window boundaries. Mainnet-only gate.
Fetches cosigner stake refs by matching owner pkh against
proposal.cosigners.

dao_stake_destroy_unsigned fetches the wallet's stake (via owner
pkh match), pulls StakeST asset name from chain, burns it.
This commit is contained in:
Sulkta 2026-05-06 07:00:48 -07:00
parent a806329baa
commit e4830ef250
4 changed files with 1399 additions and 1 deletions

View file

@ -40,6 +40,10 @@ use aldabra_dao::builder::proposal_vote::{
use aldabra_dao::builder::proposal_cosign::{
build_unsigned_proposal_cosign, ProposalCosignArgs,
};
use aldabra_dao::builder::proposal_advance::{
build_unsigned_proposal_advance, AdvanceTransition, CosignerStakeRef, ProposalAdvanceArgs,
};
use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs};
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs};
use aldabra_dao::discovery::{
apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER,
@ -1802,6 +1806,305 @@ impl WalletService {
.to_string())
}
#[tool(
name = "dao_stake_destroy_unsigned",
description = "Build an unsigned tx that destroys this wallet's stake — burns the StakeST token and returns all locked governance tokens (TRP) + lovelace to the wallet. Owner-only (delegatees rejected). Requires the stake to have NO active locks (no Created/Voted/Cosigned ProposalLocks). Args: dao? + fee_lovelace (~2_000_000)."
)]
async fn dao_stake_destroy_unsigned(
&self,
#[tool(aggr)] DaoStakeDestroyArgs { dao, fee_lovelace }: DaoStakeDestroyArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let owner_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 == &owner_pkh,
_ => false,
})
.ok_or_else(|| {
format!(
"no stake at stakes_addr owned by this wallet's pkh {}",
hex::encode(&owner_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())?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
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 stake_st_policy_ref = ReferenceUtxo::from_str(
cfg.script_refs
.stake_st_policy
.as_deref()
.ok_or_else(|| {
"DaoConfig.script_refs.stake_st_policy missing — \
run dao_discover_scripts first".to_string()
})?,
)
.map_err(|e| e.to_string())?;
let unsigned = build_unsigned_stake_destroy(StakeDestroyArgs {
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,
},
owner_pkh,
change_address: self.inner.address.clone(),
wallet_utxos,
stake_validator_ref,
stake_st_policy_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,
"returned_gov_token_qty": unsigned.returned_gov_token_qty,
"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_proposal_advance_unsigned",
description = "Build an unsigned advance tx that pushes a proposal to its next status (Draft→VotingReady, VotingReady→Locked, or Locked→Finished — or to Finished from Draft/VotingReady when timing has expired). Caller picks the right transition from the proposal's current status + chain time. The Locked→Finished GAT-mint path (effected proposals) is Phase 4c-bis; for v1 only the InfoOnly Locked→Finished is supported. Args: dao? + proposal_id + fee_lovelace. The tool inspects current status, fetches cosigner stake refs as needed, and computes the right tx shape."
)]
async fn dao_proposal_advance_unsigned(
&self,
#[tool(aggr)] DaoProposalAdvanceArgs {
dao,
proposal_id,
fee_lovelace,
}: DaoProposalAdvanceArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
if !matches!(cfg.network, DaoNetwork::Mainnet) {
return Err(format!(
"dao_proposal_advance_unsigned only supports mainnet for v1 \
(current dao network: {:?})",
cfg.network
));
}
// 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,
cfg.proposal_addr.as_deref().unwrap_or("<unset>"),
)
})?;
let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?;
// Tip slot + 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 tip_ms = mainnet_slot_to_posix_ms(tip_slot)?;
// Compute the transition based on current status + tip time vs windows.
use aldabra_dao::agora::proposal::ProposalStatus as PS;
let st = target.datum.starting_time;
let tc = &target.datum.timing_config;
let drafting_end = st + tc.draft_time;
let voting_end = drafting_end + tc.voting_time;
let locking_end = voting_end + tc.locking_time;
let transition = match target.datum.status {
PS::Draft => {
if tip_ms < drafting_end {
AdvanceTransition::DraftToVotingReady
} else {
AdvanceTransition::DraftToFinished
}
}
PS::VotingReady => {
// Window for V→L is [voting_end, locking_end]. After that → Finished.
if tip_ms < locking_end {
AdvanceTransition::VotingReadyToLocked
} else {
AdvanceTransition::VotingReadyToFinished
}
}
PS::Locked => AdvanceTransition::LockedToFinished,
PS::Finished => {
return Err(format!(
"proposal #{} is already Finished — cannot advance further",
proposal_id
));
}
};
// For Draft→VotingReady, fetch all cosigner stakes by matching
// owner pkh against proposal.cosigners.
let mut cosigner_stake_refs = Vec::new();
if transition == AdvanceTransition::DraftToVotingReady {
let stakes = self
.inner
.dao_reader
.list_stakes(&cfg)
.await
.map_err(|e| e.to_string())?;
for cosigner in &target.datum.cosigners {
let cosigner_h = match cosigner {
aldabra_dao::agora::stake::Credential::PubKey(h) => h,
_ => {
return Err(
"script-credentialed cosigners not yet supported for advance".into(),
);
}
};
let s = stakes
.iter()
.find(|s| match &s.datum.owner {
aldabra_dao::agora::stake::Credential::PubKey(h) => h == cosigner_h,
_ => false,
})
.ok_or_else(|| {
format!(
"no on-chain stake found for cosigner pkh {} — \
cosigner may have moved their stake or destroyed it",
hex::encode(cosigner_h)
)
})?;
let (s_tx, s_idx) = parse_utxo_ref(&s.utxo_ref)?;
cosigner_stake_refs.push(CosignerStakeRef {
tx_hash_hex: s_tx,
output_index: s_idx,
owner: s.datum.owner.clone(),
staked_amount: s.datum.staked_amount,
});
}
}
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 advancer_pkh = self.wallet_pkh()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let unsigned = build_unsigned_proposal_advance(ProposalAdvanceArgs {
cfg: cfg.clone(),
proposal: ProposalUtxoIn {
tx_hash_hex: prop_tx,
output_index: prop_idx,
lovelace: target.lovelace,
proposal_st_asset_name_hex: target.proposal_st_asset_name_hex,
datum: target.datum,
},
transition,
cosigner_stake_refs,
proposal_validator_ref,
change_address: self.inner.address.clone(),
wallet_utxos,
advancer_pkh,
tip_slot,
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,
"from_status": format!("{:?}", unsigned.from_status),
"to_status": format!("{:?}", unsigned.to_status),
"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_proposal_cosign_unsigned",
description = "Build (but DO NOT submit) an unsigned cosign tx that adds the wallet's stake as a cosigner of a Draft proposal. Used to bridge a single stake's amount being below the to_voting threshold — multiple cosigners' stakes sum when the proposal advances. Only the stake owner can cosign (delegatees rejected per validator). Args: dao (optional — defaults to active), proposal_id (i64; must be in Draft), fee_lovelace (~2_500_000)."
@ -2323,6 +2626,26 @@ pub struct DaoProposalCreateArgs {
pub starting_time_ms: i64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoStakeDestroyArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Estimated total fee. ~2_000_000 reasonable for a single-stake destroy.
pub fee_lovelace: u64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoProposalAdvanceArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Proposal id to advance.
pub proposal_id: i64,
/// Estimated total fee in lovelace. ~2_500_000 reasonable.
pub fee_lovelace: u64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoProposalCosignArgs {
/// Named DAO. Falls through to active if omitted.
@ -2485,7 +2808,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). 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). 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). 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(),
),
..Default::default()
}