diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index 56614e5..2f1996b 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -20,3 +20,5 @@ pub mod proposal_create; pub mod proposal_vote; pub mod proposal_cosign; +pub mod proposal_advance; +pub mod stake_destroy; diff --git a/crates/aldabra-dao/src/builder/proposal_advance.rs b/crates/aldabra-dao/src/builder/proposal_advance.rs new file mode 100644 index 0000000..50b865c --- /dev/null +++ b/crates/aldabra-dao/src/builder/proposal_advance.rs @@ -0,0 +1,679 @@ +//! Build a `dao_proposal_advance` transaction. +//! +//! State machine that drives a proposal forward through statuses: +//! +//! ```text +//! Draft ─[in drafting period + cosign threshold met]─→ VotingReady +//! Draft ─[after drafting period]─────────────────────→ Finished (failed) +//! VotingReady ─[in locking period + winner outcome exists]─→ Locked +//! VotingReady ─[after locking period]──────────────────────→ Finished (failed) +//! Locked ─[after executing period, GST not moved]────→ Finished (effect-less) +//! Locked ─[in executing period, GST moved]───────────→ Finished (executed; GAT mint +//! happens in a separate +//! MintGATs governor tx) +//! ``` +//! +//! For v1 we ship every transition EXCEPT the in-executing-period +//! Locked→Finished path, which requires the governor MintGATs tx that +//! mints + sends GATs to effect script addresses. That's a Phase 4c-bis +//! follow-up — Sulkta has never executed a proposal so the GAT minting +//! policy isn't even on chain yet. +//! +//! ## Tx shape (per transition) +//! +//! All transitions: +//! - **Inputs**: proposal UTxO (Plutus spend, redeemer = `AdvanceProposal`) +//! + one funding wallet UTxO. +//! - **Collateral**: separate ADA-only ≥5 ADA wallet UTxO. +//! - **Reference inputs**: proposal validator script. +//! - **Outputs**: new proposal UTxO at `proposal_addr` with status +//! mutated; rest of datum bit-exact. +//! - **No mints**. +//! +//! Draft→VotingReady **also** has: +//! - Every cosigner's stake UTxO as a **reference** input (not spent). +//! This satisfies `witnessStakes` in the validator: it iterates +//! `txInfo.referenceInputs`, sums every input that resolves to a +//! StakeDatum, and verifies `sortedOwners == cosigners`. +//! +//! ## What the validator enforces +//! +//! From `Agora/Proposal/Scripts.hs` `PAdvanceProposal` (~L657): +//! +//! 1. Output proposal datum equals input with **only** `status` mutated. +//! 2. Branch by current status: +//! - **Draft**: if within drafting period, sum of cosigner stakes +//! (from ref inputs) ≥ thresholds.toVoting AND sorted owners equal +//! proposal.cosigners → output status = VotingReady. If after +//! drafting period → output status = Finished. +//! - **VotingReady**: if within locking period, `pwinner'` returns +//! Just (= a winning ResultTag exists with votes ≥ thresholds.execute +//! and beats neutralOption) → output status = Locked. If after +//! locking period → output status = Finished. +//! - **Locked**: output status = Finished. If within executing +//! period, GST must have been moved (= governor input present); +//! otherwise GST must NOT be moved. +//! - **Finished**: rejected. +//! +//! The drafting/locking/executing period definitions: +//! +//! - drafting: `[starting_time, starting_time + draft_time]` +//! - voting: `[starting_time + draft_time, +//! starting_time + draft_time + voting_time]` +//! - locking: `[starting_time + draft_time + voting_time, +//! starting_time + draft_time + voting_time + locking_time]` +//! - executing: `[starting_time + draft_time + voting_time + locking_time, +//! starting_time + draft_time + voting_time + locking_time +//! + executing_time]` + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; + +use crate::agora::proposal::{ + ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, + ProposalTimingConfig, ProposalVotes, +}; +use crate::agora::stake::Credential; +use crate::config::{DaoConfig, DaoNetwork}; +use crate::error::{DaoError, DaoResult}; + +use super::proposal_create::{ + parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, WalletUtxo, + MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as ADVANCE_SPEND_EX_UNITS, + SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS, +}; +use super::proposal_cosign::insert_unique_sorted; +use super::proposal_vote::ProposalUtxoIn; + +const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; + +/// Which transition this advance is performing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdvanceTransition { + DraftToVotingReady, + DraftToFinished, + VotingReadyToLocked, + VotingReadyToFinished, + LockedToFinished, +} + +impl AdvanceTransition { + pub fn target_status(self) -> ProposalStatus { + match self { + AdvanceTransition::DraftToVotingReady => ProposalStatus::VotingReady, + AdvanceTransition::VotingReadyToLocked => ProposalStatus::Locked, + AdvanceTransition::DraftToFinished + | AdvanceTransition::VotingReadyToFinished + | AdvanceTransition::LockedToFinished => ProposalStatus::Finished, + } + } + + pub fn from_status(self) -> ProposalStatus { + match self { + AdvanceTransition::DraftToVotingReady | AdvanceTransition::DraftToFinished => { + ProposalStatus::Draft + } + AdvanceTransition::VotingReadyToLocked + | AdvanceTransition::VotingReadyToFinished => ProposalStatus::VotingReady, + AdvanceTransition::LockedToFinished => ProposalStatus::Locked, + } + } +} + +/// Cosigner stake UTxO that needs to be referenced (not spent) when +/// advancing Draft → VotingReady. Built from on-chain `StakeUtxo` data. +#[derive(Debug, Clone)] +pub struct CosignerStakeRef { + pub tx_hash_hex: String, + pub output_index: u32, + /// Stake's owner credential — must equal one of `proposal.cosigners`. + pub owner: Credential, + pub staked_amount: i64, +} + +/// Args bundle for [`build_unsigned_proposal_advance`]. +#[derive(Debug, Clone)] +pub struct ProposalAdvanceArgs { + pub cfg: DaoConfig, + pub proposal: ProposalUtxoIn, + /// Which transition to perform. Validator branches on input status, + /// so picking the wrong one will fail. Caller computes from current + /// status + chain-tip time. + pub transition: AdvanceTransition, + /// Cosigner stake refs, ONE PER cosigner in `proposal.datum.cosigners`. + /// Only used for Draft→VotingReady; ignored for other transitions + /// but caller should pass `vec![]` for clarity. + pub cosigner_stake_refs: Vec, + /// Reference UTxO citing the proposal validator script. + pub proposal_validator_ref: ReferenceUtxo, + /// Wallet change address. + pub change_address: String, + /// Spendable wallet UTxOs (funding + collateral). + pub wallet_utxos: Vec, + /// Wallet's payment-credential hash (28 bytes) — needed for the + /// disclosed_signer; the funding utxo's vkey witness will sign. + pub advancer_pkh: Vec, + /// Current chain tip slot. + pub tip_slot: u64, + /// Estimated total fee. + pub fee_lovelace: u64, +} + +/// What [`build_unsigned_proposal_advance`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedProposalAdvance { + pub tx_cbor_hex: String, + pub tx_hash_hex: String, + pub proposal_id: i64, + pub from_status: ProposalStatus, + pub to_status: ProposalStatus, + pub summary: String, +} + +/// Build the unsigned proposal-advance tx. +pub fn build_unsigned_proposal_advance( + args: ProposalAdvanceArgs, +) -> DaoResult { + let proposal_id = args.proposal.datum.proposal_id; + let from_status = args.proposal.datum.status; + let to_status = args.transition.target_status(); + + // ---- preflight: from_status matches transition ---------------------- + + if from_status != args.transition.from_status() { + return Err(DaoError::State(format!( + "transition {:?} expects from-status {:?}, but proposal #{} is currently {:?}", + args.transition, + args.transition.from_status(), + proposal_id, + from_status + ))); + } + + // ---- preflight: per-transition rules -------------------------------- + + match args.transition { + AdvanceTransition::DraftToVotingReady => { + // (i) cosigner_stake_refs count == proposal.cosigners count. + if args.cosigner_stake_refs.len() != args.proposal.datum.cosigners.len() { + return Err(DaoError::State(format!( + "expected {} cosigner stake refs (one per cosigner), got {}", + args.proposal.datum.cosigners.len(), + args.cosigner_stake_refs.len() + ))); + } + // (ii) sorted owners list from refs equals proposal.cosigners + // (already sorted unique per the cosign builder's invariant). + // We rebuild via insert_unique_sorted so any caller that passed + // refs in arbitrary order still gets the right comparison. + let mut sorted_ref_owners: Vec = Vec::new(); + for r in &args.cosigner_stake_refs { + sorted_ref_owners = insert_unique_sorted(&sorted_ref_owners, &r.owner)?; + } + if sorted_ref_owners != args.proposal.datum.cosigners { + return Err(DaoError::State(format!( + "sorted cosigner-stake owners do not match proposal.cosigners exactly — \ + ref order or membership wrong" + ))); + } + // (iii) sum of staked_amounts ≥ thresholds.to_voting. + let total: i128 = args + .cosigner_stake_refs + .iter() + .map(|r| r.staked_amount as i128) + .sum(); + let thresh = args.proposal.datum.thresholds.to_voting as i128; + if total < thresh { + return Err(DaoError::State(format!( + "sum of cosigner staked amounts {} < to_voting threshold {}", + total, thresh + ))); + } + } + AdvanceTransition::VotingReadyToLocked => { + // pwinner' votes thresholds.execute must return Just. + // Implement client-side: find max-vote tag, check votes ≥ execute, + // and check it strictly beats every other tag. + let votes = &args.proposal.datum.votes.0; + let exec_threshold = args.proposal.datum.thresholds.execute; + let max = votes.iter().max_by_key(|(_, v)| *v).copied(); + let Some((_winner_tag, max_votes)) = max else { + return Err(DaoError::State( + "proposal has no votes map; cannot determine winner".into(), + )); + }; + if max_votes < exec_threshold { + return Err(DaoError::State(format!( + "winning votes {} < execute threshold {}", + max_votes, exec_threshold + ))); + } + // Tie check: more than one tag has max_votes → no winner. + let max_count = votes.iter().filter(|(_, v)| *v == max_votes).count(); + if max_count > 1 { + return Err(DaoError::State(format!( + "vote tie at {} between {} options; no winning outcome", + max_votes, max_count + ))); + } + } + AdvanceTransition::DraftToFinished + | AdvanceTransition::VotingReadyToFinished + | AdvanceTransition::LockedToFinished => { + // No additional preflight beyond the from-status match. The + // validator checks timing on chain — caller must ensure + // tx validity range is in the right period (we don't compute + // ms-from-slot here for simplicity; caller-or-tool's + // responsibility). + } + } + + // ---- 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 for funding".into()) + })?; + + // ---- new proposal datum: only status mutated ------------------------ + + let new_proposal = ProposalDatum { + proposal_id, + effects_raw: args.proposal.datum.effects_raw.clone(), + status: to_status, + cosigners: args.proposal.datum.cosigners.clone(), + thresholds: ProposalThresholds { + ..args.proposal.datum.thresholds.clone() + }, + votes: ProposalVotes(args.proposal.datum.votes.0.clone()), + timing_config: ProposalTimingConfig { + ..args.proposal.datum.timing_config.clone() + }, + starting_time: args.proposal.datum.starting_time, + }; + + let new_proposal_datum_pd = new_proposal.to_plutus_data()?; + let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd) + .map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?; + + let proposal_spend_redeemer_cbor = + minicbor::to_vec(&ProposalRedeemer::AdvanceProposal.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?; + + // ---- balance + change ---------------------------------------------- + + let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE); + let total_in = args + .proposal + .lovelace + .checked_add(funding.lovelace) + .ok_or_else(|| DaoError::State("input lovelace overflow".into()))?; + let total_out = new_proposal_lovelace + .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 StagingTransaction ------------------------------------ + + let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| { + DaoError::Config("proposal_addr not set on DaoConfig".into()) + })?)?; + let change_addr = parse_address(&args.change_address)?; + + 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 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 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 network_id = match args.cfg.network { + DaoNetwork::Mainnet => 1u8, + DaoNetwork::Preprod | DaoNetwork::Preview => 0u8, + }; + + 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(proposal_input.clone()); + staging = staging.input(funding_input.clone()); + staging = staging.collateral_input(collateral_input); + staging = staging.reference_input(proposal_validator_ref_input); + + // For Draft→VotingReady, every cosigner's stake utxo goes in as a + // reference input. Validator iterates txInfo.referenceInputs and + // sums their staked_amount. + if args.transition == AdvanceTransition::DraftToVotingReady { + for r in &args.cosigner_stake_refs { + let r_input = Input::new(parse_tx_hash(&r.tx_hash_hex)?, r.output_index as u64); + staging = staging.reference_input(r_input); + } + } + + 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( + proposal_input, + proposal_spend_redeemer_cbor, + Some(ADVANCE_SPEND_EX_UNITS), + ); + + staging = staging.valid_from_slot(args.tip_slot); + staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS); + + let advancer_pkh_arr: [u8; 28] = args + .advancer_pkh + .as_slice() + .try_into() + .map_err(|_| DaoError::Datum(format!( + "advancer_pkh must be 28 bytes, got {}", + args.advancer_pkh.len() + )))?; + staging = staging.disclosed_signer(Hash::<28>::from(advancer_pkh_arr)); + + staging = staging.fee(args.fee_lovelace).network_id(network_id); + + 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 summary = format!( + "dao_proposal_advance_unsigned: dao={} proposal_id={} {:?} → {:?} fee={}", + args.cfg.name, proposal_id, from_status, to_status, args.fee_lovelace, + ); + + Ok(UnsignedProposalAdvance { + tx_cbor_hex, + tx_hash_hex, + proposal_id, + from_status, + to_status, + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agora::plutus_data::constr; + use crate::config::ScriptRefs; + + fn pkh_a() -> Vec { vec![0x10; 28] } + fn pkh_b() -> Vec { vec![0x80; 28] } + fn advancer_pkh() -> Vec { + hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() + } + + fn sample_proposal_datum() -> ProposalDatum { + ProposalDatum { + proposal_id: 1, + effects_raw: constr(0, vec![]), + status: ProposalStatus::Draft, + cosigners: vec![ + Credential::PubKey(pkh_a()), + Credential::PubKey(pkh_b()), + ], + thresholds: ProposalThresholds { + execute: 20, + create: 100, + to_voting: 100, + vote: 1, + cosign: 1, + }, + votes: ProposalVotes(vec![(0, 0), (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_args( + transition: AdvanceTransition, + proposal_overrides: impl FnOnce(&mut ProposalDatum), + ) -> ProposalAdvanceArgs { + let mut datum = sample_proposal_datum(); + datum.status = transition.from_status(); + proposal_overrides(&mut datum); + + ProposalAdvanceArgs { + 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(), + }, + proposal: ProposalUtxoIn { + tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(), + output_index: 0, + lovelace: 2_000_000, + proposal_st_asset_name_hex: "".into(), + datum, + }, + transition, + cosigner_stake_refs: vec![ + CosignerStakeRef { + tx_hash_hex: "22".repeat(32), + output_index: 0, + owner: Credential::PubKey(pkh_a()), + staked_amount: 60, + }, + CosignerStakeRef { + tx_hash_hex: "33".repeat(32), + output_index: 0, + owner: Credential::PubKey(pkh_b()), + staked_amount: 60, + }, + ], + proposal_validator_ref: ReferenceUtxo { + tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(), + output_index: 1, + }, + 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![], + }, + ], + advancer_pkh: advancer_pkh(), + tip_slot: 180_062_536, + fee_lovelace: 2_500_000, + } + } + + #[test] + fn draft_to_voting_ready_happy_path() { + let args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {}); + let unsigned = build_unsigned_proposal_advance(args).unwrap(); + assert_eq!(unsigned.from_status, ProposalStatus::Draft); + assert_eq!(unsigned.to_status, ProposalStatus::VotingReady); + assert_eq!(unsigned.proposal_id, 1); + } + + #[test] + fn draft_to_voting_ready_rejects_below_threshold() { + let args = sample_args(AdvanceTransition::DraftToVotingReady, |d| { + d.thresholds.to_voting = 1000; + }); + let err = build_unsigned_proposal_advance(args).unwrap_err(); + assert!(err.to_string().contains("to_voting threshold")); + } + + #[test] + fn draft_to_voting_ready_rejects_owner_mismatch() { + let mut args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {}); + // Replace cosigner_stake_refs[0] owner with a different pkh. + args.cosigner_stake_refs[0].owner = Credential::PubKey(vec![0xff; 28]); + let err = build_unsigned_proposal_advance(args).unwrap_err(); + assert!(err.to_string().contains("do not match proposal.cosigners")); + } + + #[test] + fn draft_to_voting_ready_rejects_count_mismatch() { + let mut args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {}); + args.cosigner_stake_refs.pop(); + let err = build_unsigned_proposal_advance(args).unwrap_err(); + assert!(err.to_string().contains("cosigner stake refs")); + } + + #[test] + fn draft_to_finished_ok() { + let args = sample_args(AdvanceTransition::DraftToFinished, |_| {}); + let unsigned = build_unsigned_proposal_advance(args).unwrap(); + assert_eq!(unsigned.to_status, ProposalStatus::Finished); + } + + #[test] + fn voting_ready_to_locked_happy() { + let args = sample_args(AdvanceTransition::VotingReadyToLocked, |d| { + // Pre-set winning votes — tag 0 has 50 votes, tag 1 has 0. + // execute threshold is 20, so tag 0 wins decisively. + d.votes = ProposalVotes(vec![(0, 50), (1, 0)]); + }); + let unsigned = build_unsigned_proposal_advance(args).unwrap(); + assert_eq!(unsigned.from_status, ProposalStatus::VotingReady); + assert_eq!(unsigned.to_status, ProposalStatus::Locked); + } + + #[test] + fn voting_ready_to_locked_rejects_no_winner() { + let args = sample_args(AdvanceTransition::VotingReadyToLocked, |d| { + // Tied votes → no winner. + d.votes = ProposalVotes(vec![(0, 50), (1, 50)]); + }); + let err = build_unsigned_proposal_advance(args).unwrap_err(); + assert!(err.to_string().contains("tie")); + } + + #[test] + fn voting_ready_to_locked_rejects_below_execute() { + let args = sample_args(AdvanceTransition::VotingReadyToLocked, |d| { + d.votes = ProposalVotes(vec![(0, 5), (1, 0)]); + }); + let err = build_unsigned_proposal_advance(args).unwrap_err(); + assert!(err.to_string().contains("execute threshold")); + } + + #[test] + fn voting_ready_to_finished_ok() { + let args = sample_args(AdvanceTransition::VotingReadyToFinished, |_| {}); + let unsigned = build_unsigned_proposal_advance(args).unwrap(); + assert_eq!(unsigned.to_status, ProposalStatus::Finished); + } + + #[test] + fn locked_to_finished_ok() { + let args = sample_args(AdvanceTransition::LockedToFinished, |_| {}); + let unsigned = build_unsigned_proposal_advance(args).unwrap(); + assert_eq!(unsigned.from_status, ProposalStatus::Locked); + assert_eq!(unsigned.to_status, ProposalStatus::Finished); + } + + #[test] + fn rejects_transition_status_mismatch() { + let mut args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {}); + args.proposal.datum.status = ProposalStatus::VotingReady; + let err = build_unsigned_proposal_advance(args).unwrap_err(); + assert!(err.to_string().contains("expects from-status")); + } +} diff --git a/crates/aldabra-dao/src/builder/stake_destroy.rs b/crates/aldabra-dao/src/builder/stake_destroy.rs new file mode 100644 index 0000000..e50d669 --- /dev/null +++ b/crates/aldabra-dao/src/builder/stake_destroy.rs @@ -0,0 +1,394 @@ +//! Build a `dao_stake_destroy` transaction. +//! +//! Destroys a stake UTxO, burning its StakeST token and returning the +//! locked governance tokens (TRP for Sulkta) + lovelace to the owner's +//! wallet. +//! +//! ## Tx shape +//! +//! - **Inputs**: +//! - The stake UTxO to destroy (Plutus spend, redeemer = `Destroy`). +//! - Optionally a funding wallet UTxO (the stake's lovelace itself +//! usually covers fees, so we make funding optional via collateral +//! selection — caller still needs ≥5 ADA collateral). +//! - **Collateral**: ADA-only ≥5 ADA wallet UTxO. +//! - **Reference inputs**: stake validator + StakeST minting policy. +//! - **Mint**: -1 StakeST (asset name = stake validator script hash). +//! - **Outputs**: a single wallet output carrying `(stake.lovelace + funding - +//! fee)` ADA + the gov-token quantity that was locked. +//! +//! ## What the validator enforces +//! +//! From `Agora/Stake/Redeemers.hs` `pdestroy` (~L432): +//! +//! 1. Owner signs (`pisSignedBy False` — delegatees rejected). +//! 2. Stake is unlocked (`locked_by` is empty / no Created-or-Voted-or-Cosigned). +//! 3. No stake UTxO at `stakes_addr` in outputs (= the stake is burnt). +//! +//! From `Agora/Stake/Scripts.hs` `stakePolicy` burn branch (~L161): +//! +//! 4. `burntST == -spentST` — quantity burnt equals what's input. +//! Single-stake destroy means burn -1. + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; + +use crate::agora::stake::{Credential, 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_MINT_EX_UNITS as DESTROY_MINT_EX_UNITS, + PROPOSAL_CREATE_SPEND_EX_UNITS as DESTROY_SPEND_EX_UNITS, +}; + +const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; + +/// Args bundle for [`build_unsigned_stake_destroy`]. +#[derive(Debug, Clone)] +pub struct StakeDestroyArgs { + pub cfg: DaoConfig, + pub stake_in: StakeUtxoIn, + /// Owner's payment-credential hash. Must match `stake.owner` per + /// validator (delegatees rejected). + pub owner_pkh: Vec, + pub change_address: String, + pub wallet_utxos: Vec, + pub stake_validator_ref: ReferenceUtxo, + pub stake_st_policy_ref: ReferenceUtxo, + pub fee_lovelace: u64, +} + +/// What [`build_unsigned_stake_destroy`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedStakeDestroy { + pub tx_cbor_hex: String, + pub tx_hash_hex: String, + /// How much gov-token quantity returns to the wallet. + pub returned_gov_token_qty: u64, + pub summary: String, +} + +pub fn build_unsigned_stake_destroy( + args: StakeDestroyArgs, +) -> DaoResult { + // ---- preflight ------------------------------------------------------ + + if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.owner_pkh) { + return Err(DaoError::State( + "owner pkh must equal stake.owner — delegatees cannot destroy".into(), + )); + } + if !args.stake_in.datum.locked_by.is_empty() { + return Err(DaoError::State(format!( + "stake has {} active lock(s); destroy requires unlocked stake — \ + retract votes or wait for proposals to finish first", + args.stake_in.datum.locked_by.len() + ))); + } + + // ---- pick collateral ------------------------------------------------ + // + // Destroy doesn't strictly need extra funding — the stake utxo itself + // brings ~1.5 ADA which usually covers fees + min-utxo of the wallet + // output. But we still need a separate ADA-only 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(); + + // Optional funding utxo: pick if there's a separate one. If only one + // ada-only utxo and it's used as collateral, we don't add funding — + // the stake's own ada covers the fee. + let funding = ada_only.iter().find(|u| { + !(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index) + }); + + // ---- redeemers ------------------------------------------------------ + + let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::Destroy.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; + let mint_redeemer_cbor = minicbor::to_vec(&crate::agora::plutus_data::constr(0, vec![])) + .map_err(|e| DaoError::Cbor(format!("mint redeemer encode: {e}")))?; + + // ---- balance -------------------------------------------------------- + + let funding_lovelace = funding.map(|f| f.lovelace).unwrap_or(0); + let total_in = args + .stake_in + .lovelace + .checked_add(funding_lovelace) + .ok_or_else(|| DaoError::State("input lovelace overflow".into()))?; + let wallet_out_lovelace = total_in.checked_sub(args.fee_lovelace).ok_or_else(|| { + DaoError::State(format!( + "insufficient input: total_in={total_in} need fee={}", + args.fee_lovelace + )) + })?; + if wallet_out_lovelace < WALLET_CHANGE_MIN_LOVELACE { + return Err(DaoError::State(format!( + "wallet output lovelace {} below min ({}); add a funding utxo", + wallet_out_lovelace, WALLET_CHANGE_MIN_LOVELACE + ))); + } + + // ---- assemble ------------------------------------------------------- + + 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 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 stake_st_policy_ref_input = Input::new( + parse_tx_hash(&args.stake_st_policy_ref.tx_hash_hex)?, + args.stake_st_policy_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 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, + }; + + // The wallet output: gov-tokens unlocked + lovelace residue. + let mut wallet_output = Output::new(change_addr, wallet_out_lovelace); + if args.stake_in.gov_token_qty > 0 { + wallet_output = wallet_output + .add_asset( + gov_token_policy_hash, + gov_token_name_bytes, + args.stake_in.gov_token_qty, + ) + .map_err(|e| DaoError::Backend(format!("add gov-token to wallet output: {e}")))?; + } + // Re-emit any native assets the funding utxo brought along. + if let Some(f) = funding { + for (policy_hex, name_hex, qty) in &f.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}")))?; + wallet_output = wallet_output + .add_asset(policy, name, *qty) + .map_err(|e| DaoError::Backend(format!("add asset to wallet output: {e}")))?; + } + } + + let mut staging = StagingTransaction::new(); + staging = staging.input(stake_input.clone()); + if let Some(f) = funding { + staging = staging.input(Input::new(parse_tx_hash(&f.tx_hash_hex)?, f.output_index as u64)); + } + staging = staging.collateral_input(collateral_input); + staging = staging.reference_input(stake_validator_ref_input); + staging = staging.reference_input(stake_st_policy_ref_input); + staging = staging.output(wallet_output); + + // Burn -1 StakeST. + staging = staging + .mint_asset(stake_st_policy_hash, stake_st_asset_name, -1) + .map_err(|e| DaoError::Backend(format!("mint_asset (burn): {e}")))?; + + staging = staging.add_spend_redeemer( + stake_input, + stake_spend_redeemer_cbor, + Some(DESTROY_SPEND_EX_UNITS), + ); + staging = staging.add_mint_redeemer( + stake_st_policy_hash, + mint_redeemer_cbor, + Some(DESTROY_MINT_EX_UNITS), + ); + + let owner_pkh_arr: [u8; 28] = args + .owner_pkh + .as_slice() + .try_into() + .map_err(|_| DaoError::Datum(format!( + "owner_pkh must be 28 bytes, got {}", + args.owner_pkh.len() + )))?; + staging = staging.disclosed_signer(Hash::<28>::from(owner_pkh_arr)); + + staging = staging.fee(args.fee_lovelace).network_id(network_id); + + 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 summary = format!( + "dao_stake_destroy_unsigned: dao={} returned_gov_token_qty={} owner_pkh={} fee={}", + args.cfg.name, + args.stake_in.gov_token_qty, + hex::encode(&args.owner_pkh), + args.fee_lovelace, + ); + + Ok(UnsignedStakeDestroy { + tx_cbor_hex, + tx_hash_hex, + returned_gov_token_qty: args.stake_in.gov_token_qty, + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agora::stake::{ProposalAction, ProposalLock, StakeDatum}; + use crate::config::ScriptRefs; + + fn owner_pkh() -> Vec { + hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() + } + + fn sample_args() -> StakeDestroyArgs { + StakeDestroyArgs { + 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: None, + stake_st_policy: Some( + "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), + ), + proposal_st_policy: None, + script_refs: ScriptRefs::default(), + }, + stake_in: StakeUtxoIn { + tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(), + output_index: 1, + lovelace: 1_555_910, + gov_token_qty: 250, + stake_st_asset_name_hex: + "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(), + datum: StakeDatum { + staked_amount: 250, + owner: Credential::PubKey(owner_pkh()), + delegated_to: None, + locked_by: vec![], + }, + }, + owner_pkh: owner_pkh(), + 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![], + }, + ], + stake_validator_ref: ReferenceUtxo { + tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(), + output_index: 2, + }, + stake_st_policy_ref: ReferenceUtxo { + tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000000".into(), + output_index: 0, + }, + fee_lovelace: 2_000_000, + } + } + + #[test] + fn builds_unsigned_destroy_for_sulkta() { + let unsigned = build_unsigned_stake_destroy(sample_args()).unwrap(); + assert_eq!(unsigned.returned_gov_token_qty, 250); + assert!(!unsigned.tx_cbor_hex.is_empty()); + assert_eq!(unsigned.tx_hash_hex.len(), 64); + assert!(unsigned.summary.contains("sulkta")); + } + + #[test] + fn rejects_locked_stake() { + let mut args = sample_args(); + args.stake_in.datum.locked_by.push(ProposalLock { + proposal_id: 1, + action: ProposalAction::Created, + }); + let err = build_unsigned_stake_destroy(args).unwrap_err(); + assert!(err.to_string().contains("active lock")); + } + + #[test] + fn rejects_delegatee() { + let mut args = sample_args(); + let other = vec![0xff; 28]; + args.stake_in.datum.owner = Credential::PubKey(other.clone()); + args.stake_in.datum.delegated_to = Some(Credential::PubKey(owner_pkh())); + // owner_pkh is now the delegatee; validator rejects. + let err = build_unsigned_stake_destroy(args).unwrap_err(); + assert!(err.to_string().contains("owner")); + } + + #[test] + fn destroy_works_without_funding_utxo() { + let mut args = sample_args(); + // Only collateral; no second ada-only utxo. Stake's own ada (1.5M) + // + nothing else - 2M fee = -500k → fails because wallet output + // would go below min utxo. Let's bump stake lovelace to make it work. + args.wallet_utxos = vec![WalletUtxo { + tx_hash_hex: "00".repeat(32), + output_index: 0, + lovelace: 10_000_000, + assets: vec![], + }]; + args.stake_in.lovelace = 5_000_000; // enough to pay 2M fee + leave 3M + let unsigned = build_unsigned_stake_destroy(args).unwrap(); + assert_eq!(unsigned.returned_gov_token_qty, 250); + } +} diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index bde813f..5c14a3c 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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 { + 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 { + 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(""), + ) + })?; + 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, + /// 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, + /// 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/.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/.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() }