feat(dao): proposal_create.rs skeleton + InfoOnly builder + tests
This commit is contained in:
parent
3a7f536409
commit
3ac10f7f4b
2 changed files with 603 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
594
crates/aldabra-dao/src/builder/proposal_create.rs
Normal file
594
crates/aldabra-dao/src/builder/proposal_create.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue