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 `lucy-registry:5000/aldabra/mcp@0c79231` with StakeRedeemer::RetractVotes and ProposalRedeemer::UnlockStake already landed; this just wires the builder + MCP tool.
This commit is contained in:
parent
0c79231936
commit
1b9968cf3b
3 changed files with 1117 additions and 1 deletions
|
|
@ -21,4 +21,5 @@ pub mod proposal_create;
|
|||
pub mod proposal_vote;
|
||||
pub mod proposal_cosign;
|
||||
pub mod proposal_advance;
|
||||
pub mod proposal_retract_votes;
|
||||
pub mod stake_destroy;
|
||||
|
|
|
|||
896
crates/aldabra-dao/src/builder/proposal_retract_votes.rs
Normal file
896
crates/aldabra-dao/src/builder/proposal_retract_votes.rs
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
//! Build a `dao_proposal_retract_votes` transaction.
|
||||
//!
|
||||
//! Retract one stake's locks for a given proposal. Pairs with the proposal
|
||||
//! being spent under `UnlockStake`. Two distinct effects depending on the
|
||||
//! proposal's current status:
|
||||
//!
|
||||
//! - **Proposal in `VotingReady` (and within voting window)**: removes the
|
||||
//! stake's `Voted` lock for that proposal AND subtracts the stake's
|
||||
//! `staked_amount` from `proposal.votes[voted_tag]`. Stake's
|
||||
//! `Created`/`Cosigned` locks for that proposal are kept.
|
||||
//! - **Proposal in `Finished`**: removes ALL locks for that proposal_id
|
||||
//! (Voted + Created + Cosigned). Proposal datum is unchanged. This is the
|
||||
//! path that finally lets a stake become unlocked + destroyable after a
|
||||
//! proposal has resolved.
|
||||
//! - **Proposal in any other status / outside voting window**: removes only
|
||||
//! `Voted` locks, AND only if the lock's cooldown has elapsed
|
||||
//! (`createdAt + minStakeVotingTime ≤ tx_lower_ms`). Proposal datum is
|
||||
//! unchanged. Useful for unlocking a stake on a `Locked` proposal whose
|
||||
//! voting closed but isn't fully `Finished` yet.
|
||||
//!
|
||||
//! ## Tx shape
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The voter's stake UTxO (Plutus spend, redeemer = `RetractVotes`).
|
||||
//! - The target proposal UTxO (Plutus spend, redeemer = `UnlockStake`).
|
||||
//! - One ada-only wallet UTxO for funding.
|
||||
//! - **Collateral input**: separate ada-only ≥5 ADA wallet UTxO.
|
||||
//! - **Reference inputs**:
|
||||
//! - Stake validator script.
|
||||
//! - Proposal validator script.
|
||||
//! - **No mints**.
|
||||
//! - **Outputs**:
|
||||
//! - New stake UTxO at `stakes_addr`. Datum = old StakeDatum with
|
||||
//! `locked_by` filtered per the rules above. StakeST + gov-token qty
|
||||
//! preserved.
|
||||
//! - New proposal UTxO at `proposal_addr`. Datum is either:
|
||||
//! - votes-mutated copy of input datum (only if VotingReady + in
|
||||
//! voting window — matches `shouldUpdateVotes` in `Agora/Proposal/
|
||||
//! Scripts.hs:601-611`),
|
||||
//! - or bit-identical copy of input datum (every other case).
|
||||
//! ProposalST preserved.
|
||||
//! - Wallet change.
|
||||
//!
|
||||
//! ## What the validator enforces (must match)
|
||||
//!
|
||||
//! From `Agora/Stake/Redeemers.hs:326` `pretractVote`:
|
||||
//!
|
||||
//! 1. ProposalContext is `PSpendProposal proposal UnlockStake currentTime`
|
||||
//! — i.e. the same tx must spend a proposal under UnlockStake.
|
||||
//! 2. Owner OR delegatee signs.
|
||||
//! 3. Output stake datum equals input with only `locked_by` mutated to the
|
||||
//! filtered list.
|
||||
//! 4. Filter rule (per `premoveLocks` at `Stake/Redeemers.hs:284`):
|
||||
//! - Voted lock for proposal_id: removed iff
|
||||
//! `(mode = RemoveAllLocks)` OR
|
||||
//! `(createdAt + minStakeVotingTime ≤ lowerBound)`.
|
||||
//! If not in either case, validator errors.
|
||||
//! - Created/Cosigned lock for proposal_id: removed iff
|
||||
//! `mode = RemoveAllLocks`.
|
||||
//! - Mode = `RemoveAllLocks` if proposal is `Finished`, else
|
||||
//! `RemoveVoterLockOnly`.
|
||||
//!
|
||||
//! From `Agora/Proposal/Scripts.hs:569` `PUnlockStake` branch:
|
||||
//!
|
||||
//! 5. Every spent stake input must have `locked_by` containing at least one
|
||||
//! lock for this proposal_id (i.e. `pgetStakeRoles` must NOT return
|
||||
//! `PIrrelevant`). Pre-flighted client-side.
|
||||
//! 6. If `currentStatus == VotingReady && tx-validity inside voting period`:
|
||||
//! proposal output votes = `pretractVotes` over input stakes (subtract
|
||||
//! each voter stake's amount from its voted tag). Other proposal fields
|
||||
//! bit-identical.
|
||||
//! 7. Otherwise: proposal output = bit-identical copy of input.
|
||||
//! 8. Validity range width ≤ `votingTimeRangeMaxWidth`.
|
||||
//!
|
||||
//! ## What's NOT in v1
|
||||
//!
|
||||
//! - **Multi-stake retraction** — `pretractVotes` over multiple stakes is
|
||||
//! what the proposal validator supports; v1 supports a single stake to
|
||||
//! match the rest of our builder family.
|
||||
//! - **Selecting which lock to retract when a stake has multiple voted
|
||||
//! locks for the same proposal_id** — that shouldn't happen (the voter
|
||||
//! path prevents double-vote), but if it ever does, we retract all of
|
||||
//! them together.
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::proposal::{
|
||||
ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes,
|
||||
};
|
||||
use crate::agora::stake::{
|
||||
Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer,
|
||||
};
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
use super::proposal_create::{
|
||||
parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo,
|
||||
MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as RETRACT_SPEND_EX_UNITS,
|
||||
SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS,
|
||||
};
|
||||
use super::proposal_vote::ProposalUtxoIn;
|
||||
|
||||
/// Wallet-change min-UTxO floor. Same value used in proposal_vote.
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
|
||||
/// Args bundle for [`build_unsigned_proposal_retract_votes`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalRetractVotesArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub stake_in: StakeUtxoIn,
|
||||
pub proposal: ProposalUtxoIn,
|
||||
/// Voter's payment-credential hash (28 bytes). Must equal stake's
|
||||
/// owner pkh OR stake's delegated_to pkh.
|
||||
pub voter_pkh: Vec<u8>,
|
||||
/// Voter wallet's bech32 address (for change).
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs.
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Current chain tip slot. Sets `valid_from_slot(tip_slot)` and
|
||||
/// `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`.
|
||||
pub tip_slot: u64,
|
||||
/// POSIX-ms equivalent of the validity range's LOWER bound (= tip_slot
|
||||
/// converted to ms via the Shelley genesis epoch). Used for cooldown
|
||||
/// preflight on Voted locks: `createdAt + minStakeVotingTime ≤ this`.
|
||||
/// Caller is responsible for the slot↔ms conversion.
|
||||
pub validity_lower_ms: i64,
|
||||
/// Reference UTxO citing the stake validator script.
|
||||
pub stake_validator_ref: ReferenceUtxo,
|
||||
/// Reference UTxO citing the proposal validator script.
|
||||
pub proposal_validator_ref: ReferenceUtxo,
|
||||
/// Estimated total fee. Caller-supplied for v1.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_proposal_retract_votes`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedProposalRetractVotes {
|
||||
/// CBOR-hex of the unsigned tx body.
|
||||
pub tx_cbor_hex: String,
|
||||
/// Blake2b-256 hash of the tx body.
|
||||
pub tx_hash_hex: String,
|
||||
/// The proposal_id whose locks were retracted.
|
||||
pub proposal_id: i64,
|
||||
/// Number of locks dropped from the stake.
|
||||
pub locks_removed: usize,
|
||||
/// Vote weight subtracted from the proposal's votes map (0 if the
|
||||
/// proposal datum was unchanged — either no Voted lock or proposal
|
||||
/// outside its voting window).
|
||||
pub vote_weight_retracted: i64,
|
||||
/// Whether the new stake datum has any locks remaining for this
|
||||
/// proposal_id (true means stake is still partially locked by this
|
||||
/// proposal — e.g. still has Created lock after retracting Voted).
|
||||
pub stake_still_locked_by_this_proposal: bool,
|
||||
/// Human-readable summary.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Build the unsigned proposal-retract-votes tx.
|
||||
pub fn build_unsigned_proposal_retract_votes(
|
||||
args: ProposalRetractVotesArgs,
|
||||
) -> DaoResult<UnsignedProposalRetractVotes> {
|
||||
let proposal_id = args.proposal.datum.proposal_id;
|
||||
|
||||
// ---- preflight checks ------------------------------------------------
|
||||
|
||||
// Voter must be owner or delegatee.
|
||||
let voter_is_owner = matches!(
|
||||
&args.stake_in.datum.owner,
|
||||
Credential::PubKey(h) if *h == args.voter_pkh
|
||||
);
|
||||
let voter_is_delegate = match &args.stake_in.datum.delegated_to {
|
||||
Some(Credential::PubKey(h)) => *h == args.voter_pkh,
|
||||
_ => false,
|
||||
};
|
||||
if !voter_is_owner && !voter_is_delegate {
|
||||
return Err(DaoError::State(
|
||||
"voter pkh is neither stake owner nor delegatee — cannot retract with this stake".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Stake must have at least one lock for this proposal_id (else the
|
||||
// proposal validator's `pisIrrelevant` check rejects).
|
||||
let locks_for_proposal: Vec<&ProposalLock> = args
|
||||
.stake_in
|
||||
.datum
|
||||
.locked_by
|
||||
.iter()
|
||||
.filter(|l| l.proposal_id == proposal_id)
|
||||
.collect();
|
||||
if locks_for_proposal.is_empty() {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake has no locks for proposal #{} — nothing to retract",
|
||||
proposal_id
|
||||
)));
|
||||
}
|
||||
|
||||
// Decide the retract mode the validator will see.
|
||||
let mode = if args.proposal.datum.status == ProposalStatus::Finished {
|
||||
RetractMode::RemoveAllLocks
|
||||
} else {
|
||||
RetractMode::RemoveVoterLockOnly
|
||||
};
|
||||
|
||||
// Determine voting-window state. Matters because the proposal validator
|
||||
// only allows votes-mutation if `status == VotingReady && tx_validity
|
||||
// inside [start+draft, start+draft+voting]`.
|
||||
let voting_start_ms =
|
||||
args.proposal.datum.starting_time + args.proposal.datum.timing_config.draft_time;
|
||||
let voting_end_ms = voting_start_ms + args.proposal.datum.timing_config.voting_time;
|
||||
let tx_lower_ms = args.validity_lower_ms;
|
||||
let tx_upper_ms = tx_lower_ms
|
||||
+ (VALIDITY_RANGE_SLOTS as i64) * 1000;
|
||||
let in_voting_window = tx_lower_ms >= voting_start_ms && tx_upper_ms <= voting_end_ms;
|
||||
let proposal_datum_will_change = args.proposal.datum.status == ProposalStatus::VotingReady
|
||||
&& in_voting_window;
|
||||
|
||||
// Voter cooldown preflight (only applies when removing Voted locks
|
||||
// outside the RemoveAllLocks path — i.e. proposal is NOT Finished).
|
||||
// Per `premoveLocks`, a Voted lock must satisfy
|
||||
// `createdAt + minStakeVotingTime ≤ lowerBound` to be removable.
|
||||
let unlock_cooldown = args.proposal.datum.timing_config.min_stake_voting_time;
|
||||
let mut voted_lock_to_retract: Option<&ProposalLock> = None;
|
||||
for lock in &locks_for_proposal {
|
||||
if let ProposalAction::Voted { posix_time, .. } = &lock.action {
|
||||
if matches!(mode, RetractMode::RemoveVoterLockOnly) {
|
||||
let ready_at = posix_time
|
||||
.checked_add(unlock_cooldown)
|
||||
.ok_or_else(|| DaoError::State("cooldown overflow".into()))?;
|
||||
if tx_lower_ms < ready_at {
|
||||
return Err(DaoError::State(format!(
|
||||
"Voted lock for proposal #{} not past cooldown yet: \
|
||||
tx_lower_ms={} < createdAt({})+minStakeVotingTime({})={}",
|
||||
proposal_id,
|
||||
tx_lower_ms,
|
||||
posix_time,
|
||||
unlock_cooldown,
|
||||
ready_at
|
||||
)));
|
||||
}
|
||||
}
|
||||
voted_lock_to_retract = Some(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- compute new datums ---------------------------------------------
|
||||
|
||||
// Filter `locked_by` per the validator's `premoveLocks` rule.
|
||||
let mut new_locks: Vec<ProposalLock> = Vec::with_capacity(args.stake_in.datum.locked_by.len());
|
||||
let mut locks_removed = 0usize;
|
||||
for lock in &args.stake_in.datum.locked_by {
|
||||
let keep = if lock.proposal_id != proposal_id {
|
||||
// Different proposal — keep.
|
||||
true
|
||||
} else {
|
||||
match (&mode, &lock.action) {
|
||||
// RemoveAll: drop everything for this proposal_id.
|
||||
(RetractMode::RemoveAllLocks, _) => false,
|
||||
// RemoveVoterOnly: drop only Voted locks.
|
||||
(RetractMode::RemoveVoterLockOnly, ProposalAction::Voted { .. }) => false,
|
||||
// Created/Cosigned in voter-only mode: keep.
|
||||
(RetractMode::RemoveVoterLockOnly, _) => true,
|
||||
}
|
||||
};
|
||||
if keep {
|
||||
new_locks.push(lock.clone());
|
||||
} else {
|
||||
locks_removed += 1;
|
||||
}
|
||||
}
|
||||
let new_stake = StakeDatum {
|
||||
staked_amount: args.stake_in.datum.staked_amount,
|
||||
owner: args.stake_in.datum.owner.clone(),
|
||||
delegated_to: args.stake_in.datum.delegated_to.clone(),
|
||||
locked_by: new_locks.clone(),
|
||||
};
|
||||
|
||||
// Build the new proposal datum. Two paths:
|
||||
// - VotingReady + in voting window AND we have a Voted lock to retract:
|
||||
// proposal.votes[voted_tag] -= stake.staked_amount.
|
||||
// - Otherwise: bit-identical copy of input datum.
|
||||
let mut vote_weight_retracted: i64 = 0;
|
||||
let new_proposal_datum = if proposal_datum_will_change {
|
||||
if let Some(ProposalLock {
|
||||
action: ProposalAction::Voted { result_tag, .. },
|
||||
..
|
||||
}) = voted_lock_to_retract.cloned()
|
||||
{
|
||||
// Subtract this stake's vote weight from the matching tag.
|
||||
let mut new_votes_inner = args.proposal.datum.votes.0.clone();
|
||||
let mut found = false;
|
||||
for (k, v) in new_votes_inner.iter_mut() {
|
||||
if *k == result_tag {
|
||||
let stake_amt = args.stake_in.datum.staked_amount;
|
||||
*v = v.checked_sub(stake_amt).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"vote retract underflow: votes[{result_tag}]={} - staked_amount={}",
|
||||
v, stake_amt
|
||||
))
|
||||
})?;
|
||||
vote_weight_retracted = stake_amt;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(DaoError::State(format!(
|
||||
"voted result_tag {} not present in proposal.votes — datum corruption?",
|
||||
result_tag
|
||||
)));
|
||||
}
|
||||
ProposalDatum {
|
||||
proposal_id,
|
||||
effects_raw: args.proposal.datum.effects_raw.clone(),
|
||||
status: args.proposal.datum.status,
|
||||
cosigners: args.proposal.datum.cosigners.clone(),
|
||||
thresholds: args.proposal.datum.thresholds.clone(),
|
||||
votes: ProposalVotes(new_votes_inner),
|
||||
timing_config: args.proposal.datum.timing_config.clone(),
|
||||
starting_time: args.proposal.datum.starting_time,
|
||||
}
|
||||
} else {
|
||||
// VotingReady + window-open but no Voted lock to retract — datum
|
||||
// must be unchanged per `shouldUpdateVotes` requiring votes to
|
||||
// ACTUALLY change ("Votes changed" trace). We fall through to
|
||||
// unchanged-datum path.
|
||||
args.proposal.datum.clone()
|
||||
}
|
||||
} else {
|
||||
args.proposal.datum.clone()
|
||||
};
|
||||
|
||||
// Re-evaluate whether the proposal datum is bit-identical or mutated.
|
||||
// If bit-identical, the validator takes the "Proposal unchanged" branch.
|
||||
let proposal_datum_actually_changed = vote_weight_retracted != 0;
|
||||
if proposal_datum_will_change && !proposal_datum_actually_changed {
|
||||
// VotingReady + in-window but no votes to retract — validator
|
||||
// still requires the votes-mutation branch (shouldUpdateVotes=true)
|
||||
// and "Votes changed" assertion will fail. Bail out client-side.
|
||||
return Err(DaoError::State(format!(
|
||||
"proposal #{} is VotingReady + in voting window but stake has no Voted lock — \
|
||||
validator requires votes to change in this branch. Wait until proposal \
|
||||
advances out of VotingReady (or window closes) before retracting Created/Cosigned.",
|
||||
proposal_id
|
||||
)));
|
||||
}
|
||||
|
||||
let new_stake_datum_pd = new_stake.to_plutus_data()?;
|
||||
let new_proposal_datum_pd = new_proposal_datum.to_plutus_data()?;
|
||||
let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?;
|
||||
let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?;
|
||||
|
||||
// ---- redeemers -------------------------------------------------------
|
||||
|
||||
let stake_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&StakeRedeemer::RetractVotes.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?;
|
||||
let proposal_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&ProposalRedeemer::UnlockStake.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?;
|
||||
|
||||
// ---- pick funding + collateral ---------------------------------------
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex
|
||||
&& u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(
|
||||
"need a SECOND ada-only wallet UTxO to fund the spend (separate from collateral)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// ---- balance + change ------------------------------------------------
|
||||
|
||||
let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let total_in = args
|
||||
.stake_in
|
||||
.lovelace
|
||||
.checked_add(args.proposal.lovelace)
|
||||
.and_then(|x| x.checked_add(funding.lovelace))
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let total_out = new_stake_lovelace
|
||||
.checked_add(new_proposal_lovelace)
|
||||
.and_then(|x| x.checked_add(args.fee_lovelace))
|
||||
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need={total_out}"
|
||||
))
|
||||
})?;
|
||||
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE})"
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble pallas StagingTransaction ------------------------------
|
||||
|
||||
let stakes_addr = parse_address(&args.cfg.stakes_addr)?;
|
||||
let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"proposal_addr not set on DaoConfig — register or discover_scripts first".into(),
|
||||
)
|
||||
})?)?;
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let stake_input = Input::new(
|
||||
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
||||
args.stake_in.output_index as u64,
|
||||
);
|
||||
let proposal_input = Input::new(
|
||||
parse_tx_hash(&args.proposal.tx_hash_hex)?,
|
||||
args.proposal.output_index as u64,
|
||||
);
|
||||
let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.output_index as u64);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let stake_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?,
|
||||
args.stake_validator_ref.output_index as u64,
|
||||
);
|
||||
let proposal_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?,
|
||||
args.proposal_validator_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let stake_st_policy_hash = parse_script_hash(args.cfg.stake_st_policy.as_deref().ok_or_else(|| {
|
||||
DaoError::Config("stake_st_policy not set on DaoConfig".into())
|
||||
})?)?;
|
||||
let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?;
|
||||
let proposal_st_policy_hash = parse_script_hash(args.cfg.proposal_st_policy.as_deref().ok_or_else(|| {
|
||||
DaoError::Config("proposal_st_policy not set on DaoConfig".into())
|
||||
})?)?;
|
||||
let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?;
|
||||
let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?;
|
||||
let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
let new_stake_output = Output::new(stakes_addr, new_stake_lovelace)
|
||||
.set_inline_datum(new_stake_datum_cbor.clone())
|
||||
.add_asset(stake_st_policy_hash, stake_st_asset_name, 1)
|
||||
.and_then(|o| o.add_asset(
|
||||
gov_token_policy_hash,
|
||||
gov_token_name_bytes,
|
||||
args.stake_in.gov_token_qty,
|
||||
))
|
||||
.map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?;
|
||||
|
||||
let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace)
|
||||
.set_inline_datum(new_proposal_datum_cbor.clone())
|
||||
.add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
staging = staging.input(stake_input.clone());
|
||||
staging = staging.input(proposal_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
staging = staging.reference_input(stake_validator_ref_input);
|
||||
staging = staging.reference_input(proposal_validator_ref_input);
|
||||
staging = staging.output(new_stake_output);
|
||||
staging = staging.output(new_proposal_output);
|
||||
|
||||
if change_lovelace > 0 {
|
||||
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
change_output = change_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_output);
|
||||
}
|
||||
|
||||
staging = staging.add_spend_redeemer(
|
||||
stake_input,
|
||||
stake_spend_redeemer_cbor,
|
||||
Some(RETRACT_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_spend_redeemer(
|
||||
proposal_input,
|
||||
proposal_spend_redeemer_cbor,
|
||||
Some(RETRACT_SPEND_EX_UNITS),
|
||||
);
|
||||
|
||||
// Validity range. For Finished proposals, no window constraint applies
|
||||
// (proposal datum is unchanged, no `inVotingPeriod` check). For
|
||||
// VotingReady + window-open, we must stay within [voting_start,
|
||||
// voting_end] for the votes-mutation path. For other statuses, no
|
||||
// constraint. We use a wide-by-default range and the caller (MCP) can
|
||||
// narrow via tip-slot picking if needed.
|
||||
staging = staging.valid_from_slot(args.tip_slot);
|
||||
staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS);
|
||||
|
||||
// Disclosed signer: voter pkh.
|
||||
let voter_pkh_arr: [u8; 28] = args
|
||||
.voter_pkh
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| DaoError::Datum(format!(
|
||||
"voter_pkh must be 28 bytes, got {}",
|
||||
args.voter_pkh.len()
|
||||
)))?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(voter_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// V2 cost model — same fix as proposal_create / proposal_advance / etc.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let stake_still_locked_by_this_proposal =
|
||||
new_locks.iter().any(|l| l.proposal_id == proposal_id);
|
||||
|
||||
let summary = format!(
|
||||
"dao_proposal_retract_votes_unsigned: dao={} proposal_id={} mode={:?} \
|
||||
locks_removed={} vote_weight_retracted={} stake_still_locked={} fee={}",
|
||||
args.cfg.name,
|
||||
proposal_id,
|
||||
mode,
|
||||
locks_removed,
|
||||
vote_weight_retracted,
|
||||
stake_still_locked_by_this_proposal,
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedProposalRetractVotes {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
proposal_id,
|
||||
locks_removed,
|
||||
vote_weight_retracted,
|
||||
stake_still_locked_by_this_proposal,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors Agora's `PRemoveLocksMode`. Selected by inspecting the proposal's
|
||||
/// status — not a caller-supplied arg, since picking the wrong mode would
|
||||
/// just get rejected by the validator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RetractMode {
|
||||
/// Drop only `Voted` locks for the proposal_id (and only those past
|
||||
/// cooldown). Kept: `Created`, `Cosigned`. Selected when proposal is
|
||||
/// not `Finished`.
|
||||
RemoveVoterLockOnly,
|
||||
/// Drop ALL locks for the proposal_id. Selected when proposal is
|
||||
/// `Finished` — the only path that lets a stake get fully unlocked
|
||||
/// after a proposal it created/cosigned has resolved.
|
||||
RemoveAllLocks,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::plutus_data::constr;
|
||||
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
use crate::config::ScriptRefs;
|
||||
|
||||
fn voter_pkh_bytes() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn sample_proposal_datum_finished() -> ProposalDatum {
|
||||
ProposalDatum {
|
||||
proposal_id: 7,
|
||||
effects_raw: constr(0, vec![]),
|
||||
status: ProposalStatus::Finished,
|
||||
cosigners: vec![Credential::PubKey(voter_pkh_bytes())],
|
||||
thresholds: ProposalThresholds {
|
||||
execute: 20,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
votes: ProposalVotes(vec![(0, 250), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60 * 60 * 1000,
|
||||
voting_time_range_max_width: 30 * 60 * 1000,
|
||||
},
|
||||
starting_time: 1_780_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_proposal_datum_voting_ready() -> ProposalDatum {
|
||||
let mut d = sample_proposal_datum_finished();
|
||||
d.proposal_id = 5;
|
||||
d.status = ProposalStatus::VotingReady;
|
||||
d
|
||||
}
|
||||
|
||||
fn sample_args(
|
||||
proposal_datum: ProposalDatum,
|
||||
stake_locks: Vec<ProposalLock>,
|
||||
validity_lower_ms: i64,
|
||||
) -> ProposalRetractVotesArgs {
|
||||
ProposalRetractVotesArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: Some(
|
||||
"addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(),
|
||||
),
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: Some(
|
||||
"9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(),
|
||||
),
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(),
|
||||
output_index: 1,
|
||||
lovelace: 5_000_000,
|
||||
gov_token_qty: 250,
|
||||
stake_st_asset_name_hex:
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(),
|
||||
datum: StakeDatum {
|
||||
staked_amount: 250,
|
||||
owner: Credential::PubKey(voter_pkh_bytes()),
|
||||
delegated_to: None,
|
||||
locked_by: stake_locks,
|
||||
},
|
||||
},
|
||||
proposal: ProposalUtxoIn {
|
||||
tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(),
|
||||
output_index: 0,
|
||||
lovelace: 2_000_000,
|
||||
proposal_st_asset_name_hex: "".into(),
|
||||
datum: proposal_datum,
|
||||
},
|
||||
voter_pkh: voter_pkh_bytes(),
|
||||
change_address:
|
||||
"addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6"
|
||||
.into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "01",
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "02",
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
tip_slot: 180_062_536,
|
||||
validity_lower_ms,
|
||||
stake_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 2,
|
||||
},
|
||||
proposal_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(),
|
||||
output_index: 1,
|
||||
},
|
||||
fee_lovelace: 2_500_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_proposal_drops_all_locks_for_id() {
|
||||
// Stake has Created lock for #7 (Finished) and Voted lock for #7,
|
||||
// plus Created for #99 (other proposal). Retract on #7 should drop
|
||||
// both #7 locks, keep #99.
|
||||
let locks = vec![
|
||||
ProposalLock {
|
||||
proposal_id: 7,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: 1_780_000_000_000,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 7,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 99,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
];
|
||||
let args = sample_args(
|
||||
sample_proposal_datum_finished(),
|
||||
locks,
|
||||
1_780_010_000_000,
|
||||
);
|
||||
let unsigned = build_unsigned_proposal_retract_votes(args).expect("build");
|
||||
assert_eq!(unsigned.proposal_id, 7);
|
||||
assert_eq!(unsigned.locks_removed, 2);
|
||||
// Finished + datum unchanged → vote_weight_retracted reports 0 even
|
||||
// if a Voted lock was dropped, because we don't mutate the proposal.
|
||||
assert_eq!(unsigned.vote_weight_retracted, 0);
|
||||
assert!(!unsigned.stake_still_locked_by_this_proposal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voting_ready_in_window_subtracts_vote_weight() {
|
||||
// VotingReady proposal #5 with starting_time + draft = voting_start.
|
||||
// Pick validity_lower in the voting window. Stake has Voted lock on
|
||||
// #5 for tag 0. Retract should subtract 250 from votes[0] and
|
||||
// remove ONLY the Voted lock (Created locks for #5 stay).
|
||||
let proposal = sample_proposal_datum_voting_ready();
|
||||
let voting_start = proposal.starting_time + proposal.timing_config.draft_time;
|
||||
let validity_lower = voting_start + 60_000; // 1 min into voting window
|
||||
let locks = vec![
|
||||
ProposalLock {
|
||||
proposal_id: 5,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: voting_start - 30_000,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 5,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
];
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let unsigned = build_unsigned_proposal_retract_votes(args).expect("build");
|
||||
assert_eq!(unsigned.proposal_id, 5);
|
||||
assert_eq!(unsigned.locks_removed, 1);
|
||||
assert_eq!(unsigned.vote_weight_retracted, 250);
|
||||
assert!(unsigned.stake_still_locked_by_this_proposal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_no_locks_for_proposal() {
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id: 99,
|
||||
action: ProposalAction::Created,
|
||||
}];
|
||||
let args = sample_args(
|
||||
sample_proposal_datum_finished(),
|
||||
locks,
|
||||
1_780_010_000_000,
|
||||
);
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(err.to_string().contains("no locks for proposal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voted_lock_in_cooldown() {
|
||||
// VotingReady but voter cooldown not yet elapsed. Locked status not
|
||||
// Finished → RemoveVoterLockOnly mode → cooldown applies.
|
||||
let mut proposal = sample_proposal_datum_voting_ready();
|
||||
proposal.status = ProposalStatus::Locked; // voting closed; not Finished
|
||||
let proposal_id = proposal.proposal_id;
|
||||
let voted_at = 1_780_000_500_000i64;
|
||||
let cooldown_ms = proposal.timing_config.min_stake_voting_time;
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: voted_at,
|
||||
},
|
||||
}];
|
||||
let validity_lower = voted_at + cooldown_ms - 1; // 1 ms shy of cooldown
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(err.to_string().contains("not past cooldown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voting_ready_in_window_without_voted_lock() {
|
||||
// VotingReady + in-window but stake only has Created lock — the
|
||||
// validator's "Votes changed" assertion would fail. Builder must
|
||||
// bail client-side.
|
||||
let proposal = sample_proposal_datum_voting_ready();
|
||||
let voting_start = proposal.starting_time + proposal.timing_config.draft_time;
|
||||
let validity_lower = voting_start + 60_000;
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id: 5,
|
||||
action: ProposalAction::Created,
|
||||
}];
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("validator requires votes to change"),
|
||||
"unexpected err: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voter_neither_owner_nor_delegate() {
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id: 7,
|
||||
action: ProposalAction::Created,
|
||||
}];
|
||||
let mut args = sample_args(
|
||||
sample_proposal_datum_finished(),
|
||||
locks,
|
||||
1_780_010_000_000,
|
||||
);
|
||||
args.voter_pkh = vec![0xee; 28];
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(err.to_string().contains("neither stake owner nor delegatee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_status_after_voting_window_drops_voted_lock_only() {
|
||||
// Locked status → RemoveVoterLockOnly. Cooldown elapsed →
|
||||
// Voted lock dropped, Created kept. Proposal datum unchanged
|
||||
// (not VotingReady → shouldUpdateVotes = false).
|
||||
let mut proposal = sample_proposal_datum_voting_ready();
|
||||
proposal.status = ProposalStatus::Locked;
|
||||
let proposal_id = proposal.proposal_id;
|
||||
let voted_at = proposal.starting_time + proposal.timing_config.draft_time;
|
||||
let cooldown_ms = proposal.timing_config.min_stake_voting_time;
|
||||
let validity_lower = voted_at + cooldown_ms + 1_000;
|
||||
let locks = vec![
|
||||
ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 1,
|
||||
posix_time: voted_at,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
];
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let unsigned = build_unsigned_proposal_retract_votes(args).expect("build");
|
||||
assert_eq!(unsigned.locks_removed, 1);
|
||||
assert_eq!(unsigned.vote_weight_retracted, 0); // proposal datum unchanged
|
||||
assert!(unsigned.stake_still_locked_by_this_proposal); // Created still there
|
||||
}
|
||||
}
|
||||
|
|
@ -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