Without staging.language_view(), pallas does not compute script_data_hash. Chain rejects the tx with PPViewHashesDontMatch. Same trap that plutus_mint hit on 2026-05-07 — same fix here. Caught attempting first dao_proposal_create_unsigned on preprod_test DAO 2026-05-07 PM after deploying governor + proposal validator ref UTxOs via the new file-path workaround for the MCP large-string bug.
828 lines
35 KiB
Rust
828 lines
35 KiB
Rust
//! Build a `dao_proposal_create` transaction.
|
|
//!
|
|
//! This is the first DAO write path. The tx shape:
|
|
//!
|
|
//! - **Inputs**:
|
|
//! - The current governor UTxO (Plutus spend, redeemer = `CreateProposal`).
|
|
//! - One wallet UTxO funding fees + min-UTxO for the new outputs.
|
|
//! - **Collateral input**: a separate ADA-only ≥5 ADA wallet UTxO.
|
|
//! - **Reference inputs**:
|
|
//! - Governor validator script (so we don't inline 7213B).
|
|
//! - ProposalST minting policy script.
|
|
//! - **Mints**: +1 ProposalST token (asset_name = empty, qty=1).
|
|
//! - **Outputs**:
|
|
//! - New governor UTxO at `governor_addr`. Datum = old GovernorDatum
|
|
//! with `next_proposal_id += 1`. Lovelace preserved.
|
|
//! - New proposal UTxO at `proposal_addr`. Datum = fresh
|
|
//! `ProposalDatum` (status=Draft, cosigners=[proposer], copied
|
|
//! thresholds + timing, votes init to per-effect-tag zeros).
|
|
//! Holds 1 ProposalST + min-UTxO ADA.
|
|
//! - Wallet change.
|
|
//!
|
|
//! ## Why unsigned-first
|
|
//!
|
|
//! Treasury-bearing Plutus txs are too high-stakes to auto-sign. Caller
|
|
//! gets the CBOR back, audits it (decode + check structure), then signs
|
|
//! with `wallet_sign_partial` and submits via `wallet_submit_signed_tx`.
|
|
//! Mirrors the cold-signing pattern aldabra already supports for
|
|
//! `wallet_send_unsigned`.
|
|
//!
|
|
//! ## What's NOT in v1 (deferred)
|
|
//!
|
|
//! - **ExUnits via Koios `tx_evaluate`** — we use a generous static
|
|
//! budget (`PROPOSAL_CREATE_EX_UNITS`) for the spend + the mint
|
|
//! redeemers separately. Refine via real evaluator when we wire up.
|
|
//! - **Non-empty `effects` map** — InfoOnly proposals only for v1.
|
|
//! TreasuryWithdrawal effects need the effect-script address +
|
|
//! datum-hash plumbing (Phase 4c).
|
|
//! - **Multi-cosigner pre-population** — proposer is sole cosigner at
|
|
//! creation. Additional cosigners join via `dao_proposal_cosign`.
|
|
|
|
use pallas_addresses::Address;
|
|
use pallas_codec::minicbor;
|
|
use pallas_codec::utils::KeyValuePairs;
|
|
use pallas_crypto::hash::Hash;
|
|
use pallas_primitives::PlutusData;
|
|
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
|
|
|
use crate::agora::governor::GovernorDatum;
|
|
use crate::agora::proposal::{
|
|
ProposalDatum, ProposalStatus, ProposalThresholds, ProposalTimingConfig, ProposalVotes,
|
|
};
|
|
use crate::agora::stake::{
|
|
Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer,
|
|
};
|
|
use crate::config::{DaoConfig, DaoNetwork};
|
|
use crate::error::{DaoError, DaoResult};
|
|
|
|
/// Per-script ExUnits budget for proposal_create.
|
|
///
|
|
/// **AUDIT-H2 fix 2026-05-05:** Original values were 14M mem / 10G steps
|
|
/// each — equal to per-tx Conway max. With 3 plutus contracts firing
|
|
/// (governor spend + stake spend + ProposalST mint), the total claim
|
|
/// would exceed the per-tx cap and node rejects pre-phase-2.
|
|
///
|
|
/// The reference tx (`7c8db1432a07...`) used 1208B tx size + 573_553
|
|
/// lovelace fee, suggesting much smaller ExUnits per script. Drop to
|
|
/// ~5M mem / 2G steps each — gives ~15M / 6G total (still under the
|
|
/// 14M / 10G per-tx cap; node may bump per-tx caps in newer Conway
|
|
/// epochs). Refine via Koios `tx_evaluate` once we have a working
|
|
/// unsigned tx to evaluate.
|
|
pub const PROPOSAL_CREATE_SPEND_EX_UNITS: ExUnits = ExUnits {
|
|
mem: 5_000_000,
|
|
steps: 2_000_000_000,
|
|
};
|
|
|
|
pub const PROPOSAL_CREATE_MINT_EX_UNITS: ExUnits = ExUnits {
|
|
mem: 2_000_000,
|
|
steps: 1_000_000_000,
|
|
};
|
|
|
|
/// Conway-era min UTxO floor we apply to script outputs. Real value
|
|
/// depends on the output's serialized size; this constant is a generous
|
|
/// bound that covers our governor + proposal output shapes.
|
|
pub const SCRIPT_OUTPUT_MIN_LOVELACE: u64 = 2_000_000;
|
|
|
|
/// Minimum collateral lovelace per Conway. Same as
|
|
/// `aldabra_core::MIN_COLLATERAL_LOVELACE`.
|
|
pub const MIN_COLLATERAL_LOVELACE: u64 = 5_000_000;
|
|
|
|
/// One wallet UTxO available to fund or collateralize the tx.
|
|
///
|
|
/// Mirror of `aldabra_core::InputUtxo` — kept separate so this crate
|
|
/// doesn't need a hard dep on the core's input shape.
|
|
#[derive(Debug, Clone)]
|
|
pub struct WalletUtxo {
|
|
pub tx_hash_hex: String,
|
|
pub output_index: u32,
|
|
pub lovelace: u64,
|
|
/// Empty if pure-ADA UTxO; non-empty if it carries native assets
|
|
/// (those get re-emitted to the change output, not the script outputs).
|
|
pub assets: Vec<(String, String, u64)>,
|
|
}
|
|
|
|
impl WalletUtxo {
|
|
pub fn is_ada_only(&self) -> bool {
|
|
self.assets.is_empty()
|
|
}
|
|
}
|
|
|
|
/// On-chain governor state we need to spend.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GovernorUtxoIn {
|
|
pub tx_hash_hex: String,
|
|
pub output_index: u32,
|
|
pub lovelace: u64,
|
|
pub datum: GovernorDatum,
|
|
/// 56-hex Governor State Thread (GST) policy id. The new governor
|
|
/// output must carry +1 of this token to keep the singleton invariant.
|
|
pub gst_policy_hex: String,
|
|
/// Asset name (hex) of the GST token. Empty for Sulkta.
|
|
pub gst_asset_name_hex: String,
|
|
}
|
|
|
|
/// On-chain stake state we need to spend (proposer's existing stake).
|
|
///
|
|
/// AUDIT-C2 fix 2026-05-05: governor's `CreateProposal` branch hard-asserts
|
|
/// `Stake input should present`. Builder MUST take a stake utxo to spend.
|
|
/// The owner of the stake's datum must equal the tx's signer (proposer_pkh).
|
|
#[derive(Debug, Clone)]
|
|
pub struct StakeUtxoIn {
|
|
pub tx_hash_hex: String,
|
|
pub output_index: u32,
|
|
pub lovelace: u64,
|
|
/// Current Terrapin/gov-token quantity on the UTxO. Must equal
|
|
/// `datum.staked_amount` per stake validator invariant.
|
|
pub gov_token_qty: u64,
|
|
/// StakeST asset name (= stake validator script hash) for the +1 token
|
|
/// the UTxO carries. Both stakes_addr UTxOs we've seen use the
|
|
/// stake-validator script hash here.
|
|
pub stake_st_asset_name_hex: String,
|
|
/// Current StakeDatum on the UTxO. We append a `Created` ProposalLock
|
|
/// to its `locked_by` field for the new stake output.
|
|
pub datum: StakeDatum,
|
|
}
|
|
|
|
/// Reference UTxO citing a deployed Agora script.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ReferenceUtxo {
|
|
pub tx_hash_hex: String,
|
|
pub output_index: u32,
|
|
}
|
|
|
|
impl ReferenceUtxo {
|
|
/// Parse a `txhash#index` string.
|
|
pub fn from_str(s: &str) -> DaoResult<Self> {
|
|
let (h, i) = s.split_once('#').ok_or_else(|| {
|
|
DaoError::Config(format!("reference utxo {s:?} not in 'txhash#index' form"))
|
|
})?;
|
|
let idx: u32 = i.parse().map_err(|e| {
|
|
DaoError::Config(format!("reference utxo index {i:?} not a u32: {e}"))
|
|
})?;
|
|
Ok(Self {
|
|
tx_hash_hex: h.to_string(),
|
|
output_index: idx,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Args bundle for [`build_unsigned_proposal_create`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProposalCreateArgs {
|
|
pub cfg: DaoConfig,
|
|
pub governor: GovernorUtxoIn,
|
|
/// Proposer's existing stake UTxO. AUDIT-C2 — required for the
|
|
/// governor's CreateProposal branch to find a stake input. The stake's
|
|
/// owner pkh must equal `proposer_pkh`, and `staked_amount + deposit`
|
|
/// must clear `governor.proposal_thresholds.create`.
|
|
pub stake_in: StakeUtxoIn,
|
|
/// Proposer's payment-credential hash (28 bytes).
|
|
pub proposer_pkh: Vec<u8>,
|
|
/// Proposer wallet's bech32 address (for change).
|
|
pub change_address: String,
|
|
/// Spendable wallet UTxOs.
|
|
pub wallet_utxos: Vec<WalletUtxo>,
|
|
/// Chain tip's POSIX time in milliseconds. Embedded in the new
|
|
/// `ProposalDatum.starting_time` field. Must lie inside the tx's
|
|
/// validity range when converted to slots — caller handles the
|
|
/// slot↔ms conversion.
|
|
pub starting_time_ms: i64,
|
|
/// Current chain tip slot. AUDIT-C3 — sets `valid_from_slot(tip_slot)`
|
|
/// and `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`. The
|
|
/// resulting window must satisfy
|
|
/// `governor.create_proposal_time_range_max_width` (Sulkta: 30min).
|
|
pub tip_slot: u64,
|
|
/// Reference UTxO to cite for the governor validator script.
|
|
pub governor_validator_ref: ReferenceUtxo,
|
|
/// Reference UTxO to cite for the stake validator script. AUDIT-C2.
|
|
pub stake_validator_ref: ReferenceUtxo,
|
|
/// Reference UTxO to cite for the ProposalST minting policy script.
|
|
pub proposal_st_policy_ref: ReferenceUtxo,
|
|
/// Estimated total fee. v1: caller-supplied. Phase-4-late: refine
|
|
/// via Koios `tx_evaluate` + size-fee calc.
|
|
pub fee_lovelace: u64,
|
|
}
|
|
|
|
/// Validity range width in slots. Sulkta's reference tx (`7c8db1432a07...`)
|
|
/// used 1799 slots (~30 min - 1s) which fits inside
|
|
/// `create_proposal_time_range_max_width = 1_800_000ms`. Match it.
|
|
pub const VALIDITY_RANGE_SLOTS: u64 = 1799;
|
|
|
|
/// What `build_unsigned_proposal_create` returns.
|
|
#[derive(Debug, Clone)]
|
|
pub struct UnsignedProposalCreate {
|
|
/// CBOR-hex of the unsigned tx body. Pass through
|
|
/// `wallet_sign_partial` to add a vkey witness.
|
|
pub tx_cbor_hex: String,
|
|
/// Blake2b-256 hash of the tx body (for tracking submission).
|
|
pub tx_hash_hex: String,
|
|
/// The new `proposal_id` this tx will mint into existence.
|
|
pub new_proposal_id: i64,
|
|
/// Human-readable summary of what the tx does. Useful for the
|
|
/// MCP tool to print on success.
|
|
pub summary: String,
|
|
}
|
|
|
|
/// Build the unsigned proposal-creation tx.
|
|
///
|
|
/// Two-pass fee is NOT used here in v1 — the caller estimates `fee_lovelace`
|
|
/// up-front. This is a tradeoff: we get a smaller-LOC builder + the caller
|
|
/// can iterate the fee against a Koios `tx_evaluate` external loop without
|
|
/// us having to embed evaluator logic inline. v2 will fold the loop in.
|
|
pub fn build_unsigned_proposal_create(
|
|
args: ProposalCreateArgs,
|
|
) -> DaoResult<UnsignedProposalCreate> {
|
|
let new_proposal_id = args.governor.datum.next_proposal_id;
|
|
let proposer_cred = Credential::PubKey(args.proposer_pkh.clone());
|
|
|
|
// ---- preflight: stake's owner must match proposer; stake meets create-threshold ----
|
|
//
|
|
// AUDIT-C2 + governor's `CreateProposal` invariants. Catch these
|
|
// client-side rather than waste fees on a phase-2 reject.
|
|
if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.proposer_pkh) {
|
|
return Err(DaoError::State(format!(
|
|
"stake owner pkh does not match proposer pkh — proposer must own the stake input"
|
|
)));
|
|
}
|
|
let create_threshold = args.governor.datum.proposal_thresholds.create;
|
|
if (args.stake_in.datum.staked_amount as i128) < (create_threshold as i128) {
|
|
return Err(DaoError::State(format!(
|
|
"stake amount {} < create threshold {} — cosigner support not yet implemented; \
|
|
use a wallet with stake >= threshold OR (later) pass multiple cosigner stakes",
|
|
args.stake_in.datum.staked_amount, create_threshold
|
|
)));
|
|
}
|
|
let max_proposals_per_stake = args.governor.datum.maximum_created_proposals_per_stake;
|
|
let n_created = args
|
|
.stake_in
|
|
.datum
|
|
.locked_by
|
|
.iter()
|
|
.filter(|l| matches!(l.action, ProposalAction::Created))
|
|
.count() as i64;
|
|
if n_created >= max_proposals_per_stake {
|
|
return Err(DaoError::State(format!(
|
|
"stake already has {} Created proposal locks; max is {}",
|
|
n_created, max_proposals_per_stake
|
|
)));
|
|
}
|
|
|
|
// ---- pick funding + collateral ---------------------------------------
|
|
//
|
|
// Same rule as `aldabra_core::build_signed_plutus_spend`: smallest
|
|
// ada-only UTxO ≥ 5 ADA is collateral; largest remaining ada-only is
|
|
// funding. Other wallet utxos are passed through to change as-is.
|
|
|
|
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(),
|
|
)
|
|
})?;
|
|
|
|
// ---- new datums -------------------------------------------------------
|
|
|
|
// Governor: copy old datum, increment next_proposal_id.
|
|
let new_governor = GovernorDatum {
|
|
next_proposal_id: args.governor.datum.next_proposal_id + 1,
|
|
..args.governor.datum.clone()
|
|
};
|
|
|
|
// Proposal: fresh ProposalDatum (Draft, sole cosigner, copied params).
|
|
//
|
|
// AUDIT-C1 fix 2026-05-05: effects must be a NON-empty map with at least
|
|
// one neutral (empty inner map) entry, AND its keys must equal the votes
|
|
// map's keys. Per Agora `Governor/Scripts.hs:437-462` validators
|
|
// `phasNeutralEffect` (pany # pnull over inner maps) and
|
|
// `pisEffectsVotesCompatible` (effects keys == votes keys).
|
|
//
|
|
// For InfoOnly: both ResultTag(0) and ResultTag(1) map to empty inner
|
|
// maps (no effect scripts trigger regardless of vote outcome).
|
|
let empty_inner: PlutusData = PlutusData::Map(KeyValuePairs::from(
|
|
Vec::<(PlutusData, PlutusData)>::new(),
|
|
));
|
|
let effects_pd = PlutusData::Map(KeyValuePairs::from(vec![
|
|
(crate::agora::plutus_data::int(0)?, empty_inner.clone()),
|
|
(crate::agora::plutus_data::int(1)?, empty_inner),
|
|
]));
|
|
|
|
let new_proposal = ProposalDatum {
|
|
proposal_id: new_proposal_id,
|
|
effects_raw: effects_pd,
|
|
status: ProposalStatus::Draft,
|
|
cosigners: vec![proposer_cred.clone()],
|
|
thresholds: ProposalThresholds { ..args.governor.datum.proposal_thresholds.clone() },
|
|
// Vote keys MUST equal effects keys (per pisEffectsVotesCompatible).
|
|
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
|
|
timing_config: ProposalTimingConfig { ..args.governor.datum.proposal_timings.clone() },
|
|
starting_time: args.starting_time_ms,
|
|
};
|
|
|
|
// New stake datum: copy old, append Created lock for the new proposal.
|
|
let mut new_stake = args.stake_in.datum.clone();
|
|
new_stake.locked_by.push(ProposalLock {
|
|
proposal_id: new_proposal_id,
|
|
action: ProposalAction::Created,
|
|
});
|
|
|
|
let new_governor_datum_pd = new_governor.to_plutus_data()?;
|
|
let new_proposal_datum_pd = new_proposal.to_plutus_data()?;
|
|
let new_stake_datum_pd = new_stake.to_plutus_data()?;
|
|
let new_governor_datum_cbor = minicbor::to_vec(&new_governor_datum_pd)
|
|
.map_err(|e| DaoError::Cbor(format!("new governor 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}")))?;
|
|
let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd)
|
|
.map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?;
|
|
|
|
// ---- redeemers --------------------------------------------------------
|
|
//
|
|
// Governor spend: GovernorRedeemer::CreateProposal = Integer 0 (per
|
|
// EnumIsData encoding fix 2026-05-05).
|
|
//
|
|
// Stake spend: AUDIT-C2 — the reference tx (7c8db1432a07...) shows the
|
|
// stake input being spent with the stake validator invoked. Per Agora's
|
|
// design the stake validator's DepositWithdraw branch handles "modify
|
|
// stake AND register a Created lock" when the same tx has the governor's
|
|
// CreateProposal redeemer. For an InfoOnly proposal with no deposit:
|
|
// DepositWithdraw(0). The reference tx had DepositWithdraw(200) (50→250).
|
|
//
|
|
// Mint redeemer: per `Agora/Proposal/Scripts.hs:118` the policy is
|
|
// `\_gst _redeemer ctx -> ...` — redeemer is unused. Constr 0 [] is fine.
|
|
|
|
let governor_spend_redeemer_cbor =
|
|
minicbor::to_vec(&crate::agora::plutus_data::int(0)?)
|
|
.map_err(|e| DaoError::Cbor(format!("governor spend redeemer encode: {e}")))?;
|
|
let stake_spend_redeemer_cbor =
|
|
minicbor::to_vec(&StakeRedeemer::DepositWithdraw(0).to_plutus_data()?)
|
|
.map_err(|e| DaoError::Cbor(format!("stake spend 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 + change -------------------------------------------------
|
|
//
|
|
// total_in = governor + stake + funding (collateral held separately).
|
|
// outputs = new_governor + new_stake + new_proposal + change.
|
|
// Change = total_in - sum(script outputs) - fee.
|
|
|
|
let new_governor_lovelace = args.governor.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
|
let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
|
let new_proposal_lovelace = SCRIPT_OUTPUT_MIN_LOVELACE;
|
|
|
|
let total_in = args
|
|
.governor
|
|
.lovelace
|
|
.checked_add(args.stake_in.lovelace)
|
|
.and_then(|x| x.checked_add(funding.lovelace))
|
|
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
|
let total_out = new_governor_lovelace
|
|
.checked_add(new_stake_lovelace)
|
|
.and_then(|x| x.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} \
|
|
(governor_out={new_governor_lovelace} + stake_out={new_stake_lovelace} + \
|
|
proposal_out={new_proposal_lovelace} + fee={})",
|
|
args.fee_lovelace
|
|
))
|
|
})?;
|
|
// Wallet change can be a regular pubkey output — lower min-utxo floor.
|
|
// AUDIT-M2: previous code required script-floor (2 ADA) for wallet
|
|
// change; that's wrong, use 1 ADA (still conservative for Conway).
|
|
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
|
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
|
return Err(DaoError::State(format!(
|
|
"change lovelace {change_lovelace} below min UTxO ({}); top up wallet or increase funding",
|
|
WALLET_CHANGE_MIN_LOVELACE
|
|
)));
|
|
}
|
|
|
|
// ---- assemble pallas StagingTransaction -------------------------------
|
|
|
|
let governor_addr = parse_address(&args.cfg.governor_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 stakes_addr = parse_address(&args.cfg.stakes_addr)?;
|
|
let change_addr = parse_address(&args.change_address)?;
|
|
|
|
let governor_input = Input::new(
|
|
parse_tx_hash(&args.governor.tx_hash_hex)?,
|
|
args.governor.output_index as u64,
|
|
);
|
|
let stake_input = Input::new(
|
|
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
|
args.stake_in.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 governor_validator_ref_input = Input::new(
|
|
parse_tx_hash(&args.governor_validator_ref.tx_hash_hex)?,
|
|
args.governor_validator_ref.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_st_policy_ref_input = Input::new(
|
|
parse_tx_hash(&args.proposal_st_policy_ref.tx_hash_hex)?,
|
|
args.proposal_st_policy_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 — register or discover_scripts first".into(),
|
|
)
|
|
})?)?;
|
|
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 — register or discover_scripts first".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 gst_policy_hash = parse_script_hash(&args.governor.gst_policy_hex)?;
|
|
let gst_name_bytes = hex::decode(&args.governor.gst_asset_name_hex)
|
|
.map_err(|e| DaoError::Config(format!("gst_asset_name_hex decode: {e}")))?;
|
|
|
|
let network_id = match args.cfg.network {
|
|
DaoNetwork::Mainnet => 1u8,
|
|
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
|
};
|
|
|
|
// New governor output: same address, +1 GST, updated datum.
|
|
let new_governor_output = Output::new(governor_addr, new_governor_lovelace)
|
|
.set_inline_datum(new_governor_datum_cbor.clone())
|
|
.add_asset(gst_policy_hash, gst_name_bytes.clone(), 1)
|
|
.map_err(|e| DaoError::Backend(format!("add gst asset to governor output: {e}")))?;
|
|
|
|
// New stake output: same stakes_addr, same StakeST + same gov-token qty,
|
|
// datum carries the new Created lock.
|
|
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.clone(), 1)
|
|
.and_then(|o| o.add_asset(
|
|
gov_token_policy_hash,
|
|
gov_token_name_bytes.clone(),
|
|
args.stake_in.gov_token_qty,
|
|
))
|
|
.map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?;
|
|
|
|
// New proposal output: ProposalST + min-utxo + datum.
|
|
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, vec![], 1)
|
|
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
|
|
|
let mut staging = StagingTransaction::new();
|
|
// 3 regular inputs: governor (script), stake (script), funding (wallet).
|
|
staging = staging.input(governor_input.clone());
|
|
staging = staging.input(stake_input.clone());
|
|
staging = staging.input(funding_input.clone());
|
|
staging = staging.collateral_input(collateral_input);
|
|
// 3 reference inputs: governor + stake validators + ProposalST policy.
|
|
staging = staging.reference_input(governor_validator_ref_input);
|
|
staging = staging.reference_input(stake_validator_ref_input);
|
|
staging = staging.reference_input(proposal_st_policy_ref_input);
|
|
// 4 outputs (3 script + maybe 1 change): governor, stake, proposal, change.
|
|
staging = staging.output(new_governor_output);
|
|
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);
|
|
// Re-emit any native assets the funding UTxO carried (none in v1
|
|
// since we picked ada-only — but caller could pass a non-ada-only
|
|
// funding utxo via an alt args struct in the future).
|
|
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);
|
|
}
|
|
|
|
// Mint +1 ProposalST (asset_name = empty bytes per Sulkta convention).
|
|
staging = staging
|
|
.mint_asset(proposal_st_policy_hash, vec![], 1)
|
|
.map_err(|e| DaoError::Backend(format!("mint_asset: {e}")))?;
|
|
|
|
// Three plutus contract invocations: spend governor, spend stake, mint ProposalST.
|
|
staging = staging.add_spend_redeemer(
|
|
governor_input,
|
|
governor_spend_redeemer_cbor,
|
|
Some(PROPOSAL_CREATE_SPEND_EX_UNITS),
|
|
);
|
|
staging = staging.add_spend_redeemer(
|
|
stake_input,
|
|
stake_spend_redeemer_cbor,
|
|
Some(PROPOSAL_CREATE_SPEND_EX_UNITS),
|
|
);
|
|
staging = staging.add_mint_redeemer(
|
|
proposal_st_policy_hash,
|
|
mint_redeemer_cbor,
|
|
Some(PROPOSAL_CREATE_MINT_EX_UNITS),
|
|
);
|
|
|
|
// AUDIT-C3 fix: tx validity range + disclosed_signer.
|
|
//
|
|
// pvalidateProposalStartingTime requires a bounded validRange ≤
|
|
// create_proposal_time_range_max_width that includes starting_time.
|
|
// Reference tx (7c8db1432a07...) used 1799 slots = ~30 min.
|
|
//
|
|
// pauthorizedBy on the stake checks proposer's pkh appears in
|
|
// txInfoSignatories — we disclose it explicitly so pallas-txbuilder
|
|
// knows to require + emit the corresponding witness.
|
|
staging = staging.valid_from_slot(args.tip_slot);
|
|
staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS);
|
|
|
|
let proposer_pkh_arr: [u8; 28] = args
|
|
.proposer_pkh
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| DaoError::Datum(format!(
|
|
"proposer_pkh must be 28 bytes, got {}",
|
|
args.proposer_pkh.len()
|
|
)))?;
|
|
staging = staging.disclosed_signer(Hash::<28>::from(proposer_pkh_arr));
|
|
|
|
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
|
|
|
// Wire the V2 cost model so pallas computes script_data_hash. Without
|
|
// this the chain rejects with PPViewHashesDontMatch — same trap the
|
|
// plutus_mint path tripped over on 2026-05-07. All Agora validators
|
|
// we witness here (governor, stake, proposalSt policy) are PlutusV2
|
|
// on the current preprod linker output, so a single language_view
|
|
// entry covers all three.
|
|
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);
|
|
// Tx hash = blake2b-256 of the body. pallas-txbuilder gives us this back
|
|
// via the built struct's hash field.
|
|
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
|
|
|
let summary = format!(
|
|
"dao_proposal_create_unsigned: dao={} new_proposal_id={} action=InfoOnly proposer_pkh={} fee={}",
|
|
args.cfg.name,
|
|
new_proposal_id,
|
|
hex::encode(&args.proposer_pkh),
|
|
args.fee_lovelace,
|
|
);
|
|
|
|
Ok(UnsignedProposalCreate {
|
|
tx_cbor_hex,
|
|
tx_hash_hex,
|
|
new_proposal_id,
|
|
summary,
|
|
})
|
|
}
|
|
|
|
// ---------- helpers --------------------------------------------------------
|
|
//
|
|
// These are `pub(super)` so sibling builders (proposal_vote, proposal_advance,
|
|
// etc.) can reuse them without re-implementing parse logic.
|
|
|
|
pub(super) fn parse_address(bech32: &str) -> DaoResult<Address> {
|
|
Address::from_bech32(bech32).map_err(|e| DaoError::Address(e.to_string()))
|
|
}
|
|
|
|
pub(super) fn parse_tx_hash(hex_str: &str) -> DaoResult<Hash<32>> {
|
|
let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("tx_hash hex: {e}")))?;
|
|
if bytes.len() != 32 {
|
|
return Err(DaoError::Cbor(format!(
|
|
"tx_hash must be 32 bytes, got {}",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let mut arr = [0u8; 32];
|
|
arr.copy_from_slice(&bytes);
|
|
Ok(Hash::from(arr))
|
|
}
|
|
|
|
pub(super) fn parse_script_hash(hex_str: &str) -> DaoResult<Hash<28>> {
|
|
let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("script_hash hex: {e}")))?;
|
|
if bytes.len() != 28 {
|
|
return Err(DaoError::Cbor(format!(
|
|
"script_hash must be 28 bytes, got {}",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let mut arr = [0u8; 28];
|
|
arr.copy_from_slice(&bytes);
|
|
Ok(Hash::from(arr))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::agora::governor::GovernorDatum;
|
|
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
|
|
|
fn sample_governor_datum() -> GovernorDatum {
|
|
GovernorDatum {
|
|
proposal_thresholds: ProposalThresholds {
|
|
execute: 20,
|
|
create: 100,
|
|
to_voting: 100,
|
|
vote: 1,
|
|
cosign: 1,
|
|
},
|
|
next_proposal_id: 1,
|
|
proposal_timings: 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,
|
|
},
|
|
create_proposal_time_range_max_width: 30 * 60 * 1000,
|
|
maximum_created_proposals_per_stake: 20,
|
|
}
|
|
}
|
|
|
|
fn sample_args() -> ProposalCreateArgs {
|
|
ProposalCreateArgs {
|
|
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: Default::default(),
|
|
},
|
|
governor: GovernorUtxoIn {
|
|
tx_hash_hex: "7c8db1432a07143eaf7755257baf8691a8e24ee8b2f3a139fa1ce222f2821c47"
|
|
.into(),
|
|
output_index: 1,
|
|
lovelace: 1_254_210,
|
|
datum: sample_governor_datum(),
|
|
// Sulkta GST policy (discovered via tx_info on the create tx).
|
|
gst_policy_hex: "568ee4f1cb41050000000000000000000000000000000000000000ee".into(),
|
|
gst_asset_name_hex: "".into(),
|
|
},
|
|
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(
|
|
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3")
|
|
.unwrap(),
|
|
),
|
|
delegated_to: None,
|
|
locked_by: vec![],
|
|
},
|
|
},
|
|
tip_slot: 180_062_536,
|
|
stake_validator_ref: ReferenceUtxo {
|
|
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
|
output_index: 2,
|
|
},
|
|
proposer_pkh: hex::decode(
|
|
"84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3",
|
|
)
|
|
.unwrap(),
|
|
change_address: "addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6".into(),
|
|
wallet_utxos: vec![
|
|
WalletUtxo {
|
|
tx_hash_hex: "0000000000000000000000000000000000000000000000000000000000000001".into(),
|
|
output_index: 0,
|
|
lovelace: 10_000_000,
|
|
assets: vec![],
|
|
},
|
|
WalletUtxo {
|
|
tx_hash_hex: "0000000000000000000000000000000000000000000000000000000000000002".into(),
|
|
output_index: 0,
|
|
lovelace: 6_000_000,
|
|
assets: vec![],
|
|
},
|
|
],
|
|
starting_time_ms: 1_780_000_000_000,
|
|
governor_validator_ref: ReferenceUtxo {
|
|
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
|
output_index: 3,
|
|
},
|
|
proposal_st_policy_ref: ReferenceUtxo {
|
|
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
|
output_index: 0,
|
|
},
|
|
fee_lovelace: 2_500_000,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_unsigned_tx_for_sulkta_infoonly() {
|
|
let unsigned = build_unsigned_proposal_create(sample_args()).unwrap();
|
|
assert_eq!(unsigned.new_proposal_id, 1);
|
|
assert!(!unsigned.tx_cbor_hex.is_empty());
|
|
assert_eq!(unsigned.tx_hash_hex.len(), 64);
|
|
assert!(unsigned.summary.contains("sulkta"));
|
|
assert!(unsigned.summary.contains("InfoOnly"));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_when_proposal_addr_missing() {
|
|
let mut args = sample_args();
|
|
args.cfg.proposal_addr = None;
|
|
let err = build_unsigned_proposal_create(args).unwrap_err();
|
|
assert!(err.to_string().contains("proposal_addr"));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_when_no_funding_utxo() {
|
|
let mut args = sample_args();
|
|
// Only collateral-eligible utxo, no second ada-only.
|
|
args.wallet_utxos = vec![WalletUtxo {
|
|
tx_hash_hex: "00".repeat(32),
|
|
output_index: 0,
|
|
lovelace: 10_000_000,
|
|
assets: vec![],
|
|
}];
|
|
let err = build_unsigned_proposal_create(args).unwrap_err();
|
|
assert!(err.to_string().contains("SECOND ada-only"));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_when_no_collateral() {
|
|
let mut args = sample_args();
|
|
// All utxos below 5 ADA — no collateral candidate.
|
|
args.wallet_utxos = vec![WalletUtxo {
|
|
tx_hash_hex: "00".repeat(32),
|
|
output_index: 0,
|
|
lovelace: 4_000_000,
|
|
assets: vec![],
|
|
}];
|
|
let err = build_unsigned_proposal_create(args).unwrap_err();
|
|
assert!(err.to_string().contains("collateral"));
|
|
}
|
|
}
|