aldabra/crates/aldabra-dao/src/builder/proposal_create.rs
Kayos 044ebd2379 fix(dao): wire V2 cost model into proposal_create staging
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.
2026-05-07 16:57:05 -07:00

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"));
}
}