diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index 2c9b1cd..2ddcf82 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -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; diff --git a/crates/aldabra-dao/src/builder/proposal_create.rs b/crates/aldabra-dao/src/builder/proposal_create.rs new file mode 100644 index 0000000..17d9ec5 --- /dev/null +++ b/crates/aldabra-dao/src/builder/proposal_create.rs @@ -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 { + 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, + /// Proposer wallet's bech32 address (for change). + pub change_address: String, + /// Spendable wallet UTxOs. + pub wallet_utxos: Vec, + /// 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 { + 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 = 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::from_bech32(bech32).map_err(|e| DaoError::Address(e.to_string())) +} + +fn parse_tx_hash(hex_str: &str) -> DaoResult> { + 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> { + 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")); + } +}