feat(dao): proposal_advance state machine + stake_destroy + MCP tools
Phase 4c + 4d. Closes the DAO write-path arc (excluding GAT minting, which is Phase 4c-bis since Sulkta has never executed a proposal). ## proposal_advance (Phase 4c) State-machine builder with 5 transitions: - Draft → VotingReady (cosigner threshold met, all cosigner stakes ref'd as txInfo.referenceInputs, sum staked_amount ≥ to_voting) - Draft → Finished (drafting period elapsed without enough cosigners) - VotingReady → Locked (winner outcome exists with votes ≥ execute, no tie) - VotingReady → Finished (locking period elapsed without winner) - Locked → Finished (executing period elapsed; for InfoOnly proposals) Validator (PAdvanceProposal in Proposal/Scripts.hs:657) requires output proposal datum equals input with ONLY status mutated. Builder mirrors exactly. Per-transition preflights match validator gates. Cosigner stake refs go in as txInfo.referenceInputs (not regular inputs) per witnessStakes pattern (Proposal/Scripts.hs:366) — sum of staked_amount is computed from the ref-input set. GAT-minting Locked→Finished path (effected proposals) deferred to 4c-bis. The pmintGATs governor redeemer is a separate tx that fires ONLY when the executing period is in window AND the winner outcome has effects to mint GATs for. Sulkta's first proposal was InfoOnly so this path never exercised on chain yet. 11 unit tests covering every transition + every preflight reject. ## stake_destroy (Phase 4d) Burns StakeST token + returns gov-tokens to owner. From Stake/Redeemers.hs pdestroy (~L432): owner signs (no delegatees), all locks empty, no stake output at stakes_addr. From stakePolicy burn branch (~L161): burntST quantity = -spentST. Tx shape: spend stake (Destroy redeemer) + maybe a funding utxo + collateral; mint -1 StakeST; one wallet output carrying gov-tokens + (stake.lovelace + funding - fee). Funding optional — stake's own lovelace usually covers fees. 4 unit tests including the funding-optional path. ## MCP tools dao_proposal_advance_unsigned auto-picks the right transition from proposal status + chain tip vs window boundaries. Mainnet-only gate. Fetches cosigner stake refs by matching owner pkh against proposal.cosigners. dao_stake_destroy_unsigned fetches the wallet's stake (via owner pkh match), pulls StakeST asset name from chain, burns it.
This commit is contained in:
parent
39b56223f9
commit
d007817796
4 changed files with 1399 additions and 1 deletions
|
|
@ -20,3 +20,5 @@
|
|||
pub mod proposal_create;
|
||||
pub mod proposal_vote;
|
||||
pub mod proposal_cosign;
|
||||
pub mod proposal_advance;
|
||||
pub mod stake_destroy;
|
||||
|
|
|
|||
679
crates/aldabra-dao/src/builder/proposal_advance.rs
Normal file
679
crates/aldabra-dao/src/builder/proposal_advance.rs
Normal file
|
|
@ -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<CosignerStakeRef>,
|
||||
/// 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<WalletUtxo>,
|
||||
/// Wallet's payment-credential hash (28 bytes) — needed for the
|
||||
/// disclosed_signer; the funding utxo's vkey witness will sign.
|
||||
pub advancer_pkh: Vec<u8>,
|
||||
/// 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<UnsignedProposalAdvance> {
|
||||
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<Credential> = 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<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 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<u8> { vec![0x10; 28] }
|
||||
fn pkh_b() -> Vec<u8> { vec![0x80; 28] }
|
||||
fn advancer_pkh() -> Vec<u8> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
394
crates/aldabra-dao/src/builder/stake_destroy.rs
Normal file
394
crates/aldabra-dao/src/builder/stake_destroy.rs
Normal file
|
|
@ -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<u8>,
|
||||
pub change_address: String,
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
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<UnsignedStakeDestroy> {
|
||||
// ---- 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<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();
|
||||
|
||||
// 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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,10 @@ use aldabra_dao::builder::proposal_vote::{
|
|||
use aldabra_dao::builder::proposal_cosign::{
|
||||
build_unsigned_proposal_cosign, ProposalCosignArgs,
|
||||
};
|
||||
use aldabra_dao::builder::proposal_advance::{
|
||||
build_unsigned_proposal_advance, AdvanceTransition, CosignerStakeRef, ProposalAdvanceArgs,
|
||||
};
|
||||
use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs};
|
||||
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs};
|
||||
use aldabra_dao::discovery::{
|
||||
apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER,
|
||||
|
|
@ -1802,6 +1806,305 @@ impl WalletService {
|
|||
.to_string())
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "dao_stake_destroy_unsigned",
|
||||
description = "Build an unsigned tx that destroys this wallet's stake — burns the StakeST token and returns all locked governance tokens (TRP) + lovelace to the wallet. Owner-only (delegatees rejected). Requires the stake to have NO active locks (no Created/Voted/Cosigned ProposalLocks). Args: dao? + fee_lovelace (~2_000_000)."
|
||||
)]
|
||||
async fn dao_stake_destroy_unsigned(
|
||||
&self,
|
||||
#[tool(aggr)] DaoStakeDestroyArgs { dao, fee_lovelace }: DaoStakeDestroyArgs,
|
||||
) -> Result<String, String> {
|
||||
let cfg = self
|
||||
.inner
|
||||
.dao_store
|
||||
.resolve(dao.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let owner_pkh = self.wallet_pkh()?;
|
||||
let stakes = self
|
||||
.inner
|
||||
.dao_reader
|
||||
.list_stakes(&cfg)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let my_stake = stakes
|
||||
.into_iter()
|
||||
.find(|s| match &s.datum.owner {
|
||||
aldabra_dao::agora::stake::Credential::PubKey(h) => h == &owner_pkh,
|
||||
_ => false,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"no stake at stakes_addr owned by this wallet's pkh {}",
|
||||
hex::encode(&owner_pkh)
|
||||
)
|
||||
})?;
|
||||
let (stake_tx, stake_idx) = parse_utxo_ref(&my_stake.utxo_ref)?;
|
||||
|
||||
// StakeST asset name from chain.
|
||||
let stake_utxos_raw = self
|
||||
.inner
|
||||
.chain
|
||||
.get_utxos(&cfg.stakes_addr)
|
||||
.await
|
||||
.map_err(|e| format!("koios get stake utxos: {e}"))?;
|
||||
let stake_utxo_raw = stake_utxos_raw
|
||||
.into_iter()
|
||||
.find(|u| u.tx_hash == stake_tx && u.output_index == stake_idx)
|
||||
.ok_or_else(|| format!("stake utxo {} no longer on chain", my_stake.utxo_ref))?;
|
||||
let stake_st_asset_name_hex = stake_utxo_raw
|
||||
.assets
|
||||
.iter()
|
||||
.find_map(|(k, _)| {
|
||||
if k.len() < 56 {
|
||||
return None;
|
||||
}
|
||||
let (p, n) = k.split_at(56);
|
||||
if p == cfg.stake_st_policy.as_deref().unwrap_or("") {
|
||||
Some(n.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| "stake UTxO is missing StakeST token".to_string())?;
|
||||
|
||||
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
|
||||
|
||||
let stake_validator_ref = ReferenceUtxo::from_str(
|
||||
cfg.script_refs
|
||||
.stake_validator
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
"DaoConfig.script_refs.stake_validator missing — \
|
||||
run dao_discover_scripts first".to_string()
|
||||
})?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stake_st_policy_ref = ReferenceUtxo::from_str(
|
||||
cfg.script_refs
|
||||
.stake_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
"DaoConfig.script_refs.stake_st_policy missing — \
|
||||
run dao_discover_scripts first".to_string()
|
||||
})?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let unsigned = build_unsigned_stake_destroy(StakeDestroyArgs {
|
||||
cfg: cfg.clone(),
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: stake_tx,
|
||||
output_index: stake_idx,
|
||||
lovelace: my_stake.lovelace,
|
||||
gov_token_qty: my_stake.gov_token_quantity,
|
||||
stake_st_asset_name_hex,
|
||||
datum: my_stake.datum,
|
||||
},
|
||||
owner_pkh,
|
||||
change_address: self.inner.address.clone(),
|
||||
wallet_utxos,
|
||||
stake_validator_ref,
|
||||
stake_st_policy_ref,
|
||||
fee_lovelace,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"dao": cfg.name,
|
||||
"tx_cbor_hex": unsigned.tx_cbor_hex,
|
||||
"tx_hash_hex": unsigned.tx_hash_hex,
|
||||
"returned_gov_token_qty": unsigned.returned_gov_token_qty,
|
||||
"summary": unsigned.summary,
|
||||
"next_step": "review tx_cbor_hex (decode + audit), then sign via wallet_sign_partial + submit via wallet_submit_signed_tx",
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "dao_proposal_advance_unsigned",
|
||||
description = "Build an unsigned advance tx that pushes a proposal to its next status (Draft→VotingReady, VotingReady→Locked, or Locked→Finished — or to Finished from Draft/VotingReady when timing has expired). Caller picks the right transition from the proposal's current status + chain time. The Locked→Finished GAT-mint path (effected proposals) is Phase 4c-bis; for v1 only the InfoOnly Locked→Finished is supported. Args: dao? + proposal_id + fee_lovelace. The tool inspects current status, fetches cosigner stake refs as needed, and computes the right tx shape."
|
||||
)]
|
||||
async fn dao_proposal_advance_unsigned(
|
||||
&self,
|
||||
#[tool(aggr)] DaoProposalAdvanceArgs {
|
||||
dao,
|
||||
proposal_id,
|
||||
fee_lovelace,
|
||||
}: DaoProposalAdvanceArgs,
|
||||
) -> Result<String, String> {
|
||||
let cfg = self
|
||||
.inner
|
||||
.dao_store
|
||||
.resolve(dao.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !matches!(cfg.network, DaoNetwork::Mainnet) {
|
||||
return Err(format!(
|
||||
"dao_proposal_advance_unsigned only supports mainnet for v1 \
|
||||
(current dao network: {:?})",
|
||||
cfg.network
|
||||
));
|
||||
}
|
||||
|
||||
// Find the proposal.
|
||||
let proposals = self
|
||||
.inner
|
||||
.dao_reader
|
||||
.list_proposals(&cfg)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let target = proposals
|
||||
.into_iter()
|
||||
.find(|p| p.datum.proposal_id == proposal_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"no proposal with proposal_id={} found at {}",
|
||||
proposal_id,
|
||||
cfg.proposal_addr.as_deref().unwrap_or("<unset>"),
|
||||
)
|
||||
})?;
|
||||
let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?;
|
||||
|
||||
// Tip slot + ms.
|
||||
let tip_resp = self
|
||||
.inner
|
||||
.chain
|
||||
.get_raw_json("tip", &[])
|
||||
.await
|
||||
.map_err(|e| format!("koios tip: {e}"))?;
|
||||
let tip: serde_json::Value =
|
||||
serde_json::from_str(&tip_resp).map_err(|e| format!("tip parse: {e}"))?;
|
||||
let tip_slot = tip
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|t| t.get("abs_slot"))
|
||||
.and_then(|s| s.as_u64())
|
||||
.ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?;
|
||||
let tip_ms = mainnet_slot_to_posix_ms(tip_slot)?;
|
||||
|
||||
// Compute the transition based on current status + tip time vs windows.
|
||||
use aldabra_dao::agora::proposal::ProposalStatus as PS;
|
||||
let st = target.datum.starting_time;
|
||||
let tc = &target.datum.timing_config;
|
||||
let drafting_end = st + tc.draft_time;
|
||||
let voting_end = drafting_end + tc.voting_time;
|
||||
let locking_end = voting_end + tc.locking_time;
|
||||
|
||||
let transition = match target.datum.status {
|
||||
PS::Draft => {
|
||||
if tip_ms < drafting_end {
|
||||
AdvanceTransition::DraftToVotingReady
|
||||
} else {
|
||||
AdvanceTransition::DraftToFinished
|
||||
}
|
||||
}
|
||||
PS::VotingReady => {
|
||||
// Window for V→L is [voting_end, locking_end]. After that → Finished.
|
||||
if tip_ms < locking_end {
|
||||
AdvanceTransition::VotingReadyToLocked
|
||||
} else {
|
||||
AdvanceTransition::VotingReadyToFinished
|
||||
}
|
||||
}
|
||||
PS::Locked => AdvanceTransition::LockedToFinished,
|
||||
PS::Finished => {
|
||||
return Err(format!(
|
||||
"proposal #{} is already Finished — cannot advance further",
|
||||
proposal_id
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// For Draft→VotingReady, fetch all cosigner stakes by matching
|
||||
// owner pkh against proposal.cosigners.
|
||||
let mut cosigner_stake_refs = Vec::new();
|
||||
if transition == AdvanceTransition::DraftToVotingReady {
|
||||
let stakes = self
|
||||
.inner
|
||||
.dao_reader
|
||||
.list_stakes(&cfg)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
for cosigner in &target.datum.cosigners {
|
||||
let cosigner_h = match cosigner {
|
||||
aldabra_dao::agora::stake::Credential::PubKey(h) => h,
|
||||
_ => {
|
||||
return Err(
|
||||
"script-credentialed cosigners not yet supported for advance".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let s = stakes
|
||||
.iter()
|
||||
.find(|s| match &s.datum.owner {
|
||||
aldabra_dao::agora::stake::Credential::PubKey(h) => h == cosigner_h,
|
||||
_ => false,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"no on-chain stake found for cosigner pkh {} — \
|
||||
cosigner may have moved their stake or destroyed it",
|
||||
hex::encode(cosigner_h)
|
||||
)
|
||||
})?;
|
||||
let (s_tx, s_idx) = parse_utxo_ref(&s.utxo_ref)?;
|
||||
cosigner_stake_refs.push(CosignerStakeRef {
|
||||
tx_hash_hex: s_tx,
|
||||
output_index: s_idx,
|
||||
owner: s.datum.owner.clone(),
|
||||
staked_amount: s.datum.staked_amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let proposal_validator_ref = ReferenceUtxo::from_str(
|
||||
cfg.script_refs
|
||||
.proposal_validator
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
"DaoConfig.script_refs.proposal_validator missing — \
|
||||
run dao_discover_scripts first".to_string()
|
||||
})?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let advancer_pkh = self.wallet_pkh()?;
|
||||
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
|
||||
|
||||
let unsigned = build_unsigned_proposal_advance(ProposalAdvanceArgs {
|
||||
cfg: cfg.clone(),
|
||||
proposal: ProposalUtxoIn {
|
||||
tx_hash_hex: prop_tx,
|
||||
output_index: prop_idx,
|
||||
lovelace: target.lovelace,
|
||||
proposal_st_asset_name_hex: target.proposal_st_asset_name_hex,
|
||||
datum: target.datum,
|
||||
},
|
||||
transition,
|
||||
cosigner_stake_refs,
|
||||
proposal_validator_ref,
|
||||
change_address: self.inner.address.clone(),
|
||||
wallet_utxos,
|
||||
advancer_pkh,
|
||||
tip_slot,
|
||||
fee_lovelace,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"dao": cfg.name,
|
||||
"tx_cbor_hex": unsigned.tx_cbor_hex,
|
||||
"tx_hash_hex": unsigned.tx_hash_hex,
|
||||
"proposal_id": unsigned.proposal_id,
|
||||
"from_status": format!("{:?}", unsigned.from_status),
|
||||
"to_status": format!("{:?}", unsigned.to_status),
|
||||
"summary": unsigned.summary,
|
||||
"next_step": "review tx_cbor_hex (decode + audit), then sign via wallet_sign_partial + submit via wallet_submit_signed_tx",
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "dao_proposal_cosign_unsigned",
|
||||
description = "Build (but DO NOT submit) an unsigned cosign tx that adds the wallet's stake as a cosigner of a Draft proposal. Used to bridge a single stake's amount being below the to_voting threshold — multiple cosigners' stakes sum when the proposal advances. Only the stake owner can cosign (delegatees rejected per validator). Args: dao (optional — defaults to active), proposal_id (i64; must be in Draft), fee_lovelace (~2_500_000)."
|
||||
|
|
@ -2323,6 +2626,26 @@ pub struct DaoProposalCreateArgs {
|
|||
pub starting_time_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DaoStakeDestroyArgs {
|
||||
/// Named DAO. Falls through to active if omitted.
|
||||
#[serde(default)]
|
||||
pub dao: Option<String>,
|
||||
/// Estimated total fee. ~2_000_000 reasonable for a single-stake destroy.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DaoProposalAdvanceArgs {
|
||||
/// Named DAO. Falls through to active if omitted.
|
||||
#[serde(default)]
|
||||
pub dao: Option<String>,
|
||||
/// Proposal id to advance.
|
||||
pub proposal_id: i64,
|
||||
/// Estimated total fee in lovelace. ~2_500_000 reasonable.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DaoProposalCosignArgs {
|
||||
/// Named DAO. Falls through to active if omitted.
|
||||
|
|
@ -2485,7 +2808,7 @@ impl ServerHandler for WalletService {
|
|||
ServerInfo {
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
instructions: Some(
|
||||
"aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
|
||||
"aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue