feat(dao): proposal_create.rs skeleton + InfoOnly builder + tests

This commit is contained in:
Kayos 2026-05-05 19:48:21 -07:00
parent 3a7f536409
commit 3ac10f7f4b
2 changed files with 603 additions and 6 deletions

View file

@ -8,10 +8,13 @@
//!
//! | Phase | Module | What it ships |
//! |-------|-----------------------|---------------|
//! | 2 | `stake_create` | Lock TRP at stakes script with fresh StakeDatum |
//! | 4a | `proposal_create` | Spend governor (CreateProposal), mint ProposalST |
//! | 4b | `proposal_cosign` | Add additional cosigner to a Draft proposal |
//! | 3 | `proposal_vote` | Spend stake (PermitVote) + proposal (Vote tag) |
//! | 4 | `proposal_create` | Spend governor (CreateProposal), mint proposal-ST |
//! | 4 | `proposal_advance` | State-machine transition redeemer |
//! | 4 | `stake_destroy` | Spend stake (Destroy), return TRP to wallet |
//!
//! Empty for Phase 1; populated as each phase lands.
//! | 4c | `proposal_advance` | State-machine transition redeemer |
//! | 4d | `stake_destroy` | Spend stake (Destroy), return TRP to wallet |
//! | 4e | `treasury_execute` | Burn GAT + spend treasury per effect datum |
//! | def. | `stake_create` | Lock TRP at stakes script (deferred — both |
//! | | | live wallets already have stakes) |
pub mod proposal_create;

View file

@ -0,0 +1,594 @@
//! 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::Bytes;
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;
use crate::config::{DaoConfig, DaoNetwork};
use crate::error::{DaoError, DaoResult};
/// Generous default ExUnits — we burn slightly higher fees but avoid
/// "missing budget" rejections. Refine via Koios `tx_evaluate` later.
///
/// Same shape as `aldabra_core::DEFAULT_EX_UNITS` but two of them
/// (one for the spend, one for the mint redeemer).
pub const PROPOSAL_CREATE_SPEND_EX_UNITS: ExUnits = ExUnits {
mem: 14_000_000,
steps: 10_000_000_000,
};
pub const PROPOSAL_CREATE_MINT_EX_UNITS: ExUnits = ExUnits {
mem: 14_000_000,
steps: 10_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,
}
/// 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 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.
pub starting_time_ms: i64,
/// Reference UTxO to cite for the governor validator script.
pub governor_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,
}
/// 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());
// ---- 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).
let new_proposal = ProposalDatum {
proposal_id: new_proposal_id,
// InfoOnly action — empty effects map (Map ResultTag (Map ScriptHash _))
// encodes as a CBOR map with zero entries: `a0`.
effects_raw: PlutusData::Map(pallas_codec::utils::KeyValuePairs::from(
Vec::<(PlutusData, PlutusData)>::new(),
)),
status: ProposalStatus::Draft,
cosigners: vec![proposer_cred.clone()],
// Copied verbatim from governor.
thresholds: ProposalThresholds { ..args.governor.datum.proposal_thresholds.clone() },
// Init: every result tag we care about → 0 votes. For an InfoOnly
// proposal we use two tags (yes=1, no=0) by convention — Agora's
// execute logic treats votes as a winner-take-all contest by tag.
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
timing_config: ProposalTimingConfig { ..args.governor.datum.proposal_timings.clone() },
starting_time: args.starting_time_ms,
};
let new_governor_datum_pd = new_governor.to_plutus_data()?;
let new_proposal_datum_pd = new_proposal.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}")))?;
// ---- redeemers --------------------------------------------------------
//
// Spend redeemer: GovernorRedeemer::CreateProposal = Integer 0 (per
// EnumIsData encoding correction 2026-05-05).
// Mint redeemer: unit (Constr 0 []) — we don't know the exact shape
// ProposalST policy expects without source; this is the most common
// Agora pattern. Iterate via on-chain failure messages if wrong.
let spend_redeemer_pd = crate::agora::plutus_data::int(0)?;
let mint_redeemer_pd = crate::agora::plutus_data::constr(0, vec![]);
let spend_redeemer_cbor = minicbor::to_vec(&spend_redeemer_pd)
.map_err(|e| DaoError::Cbor(format!("spend redeemer encode: {e}")))?;
let mint_redeemer_cbor = minicbor::to_vec(&mint_redeemer_pd)
.map_err(|e| DaoError::Cbor(format!("mint redeemer encode: {e}")))?;
// ---- balance + change -------------------------------------------------
//
// total_in = governor + funding (collateral is held separately).
// outputs = new_governor + new_proposal + change
// The new_governor preserves the OLD governor's lovelace (Agora
// convention — script UTxOs hold a stable min-UTxO floor).
// The new_proposal needs SCRIPT_OUTPUT_MIN_LOVELACE.
// Change = funding + (governor lovelace pass-through) - new_governor_lovelace - new_proposal_lovelace - fee.
let new_governor_lovelace = args.governor.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
let new_proposal_lovelace = SCRIPT_OUTPUT_MIN_LOVELACE;
let total_in = args
.governor
.lovelace
.checked_add(funding.lovelace)
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
let total_out = new_governor_lovelace
.checked_add(new_proposal_lovelace)
.and_then(|x| x.checked_add(args.fee_lovelace))
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
DaoError::State(format!(
"insufficient input: total_in={total_in} need={total_out} \
(governor_out={new_governor_lovelace} + proposal_out={new_proposal_lovelace} + fee={})",
args.fee_lovelace
))
})?;
if change_lovelace > 0 && change_lovelace < SCRIPT_OUTPUT_MIN_LOVELACE {
return Err(DaoError::State(format!(
"change lovelace {change_lovelace} below min UTxO; top up wallet or increase funding"
)));
}
// ---- 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 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 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 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 network_id = match args.cfg.network {
DaoNetwork::Mainnet => 1u8,
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
};
// Inline-datum outputs use `add_output_datum` / `Output::new(..).set_inline_datum(..)`.
// Pallas-txbuilder's API: `Output::new(addr, lovelace).set_inline_datum(cbor_bytes)`.
let new_governor_output = Output::new(governor_addr, new_governor_lovelace)
.set_inline_datum(new_governor_datum_cbor.clone());
// The new proposal output also carries 1 ProposalST token.
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 to output: {e}")))?;
let mut staging = StagingTransaction::new();
staging = staging.input(governor_input.clone());
staging = staging.input(funding_input.clone());
staging = staging.collateral_input(collateral_input);
staging = staging.reference_input(governor_validator_ref_input);
staging = staging.reference_input(proposal_st_policy_ref_input);
staging = staging.output(new_governor_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.
staging = staging
.mint_asset(proposal_st_policy_hash, vec![], 1)
.map_err(|e| DaoError::Backend(format!("mint_asset: {e}")))?;
// Spend redeemer for the governor input + mint redeemer for ProposalST.
staging = staging.add_spend_redeemer(
governor_input,
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),
);
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);
// 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 --------------------------------------------------------
fn parse_address(bech32: &str) -> DaoResult<Address> {
Address::from_bech32(bech32).map_err(|e| DaoError::Address(e.to_string()))
}
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))
}
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(),
},
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"));
}
}