diff --git a/Cargo.lock b/Cargo.lock index ba7dcce..d519445 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aldabra-dao" +version = "0.0.1" +dependencies = [ + "aldabra-chain", + "aldabra-core", + "async-trait", + "bech32", + "hex", + "pallas-addresses", + "pallas-codec", + "pallas-crypto", + "pallas-primitives", + "pallas-traverse", + "pallas-txbuilder", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "aldabra-mcp" version = "0.0.1" @@ -104,7 +128,10 @@ dependencies = [ "age", "aldabra-chain", "aldabra-core", + "aldabra-dao", "anyhow", + "hex", + "pallas-addresses", "rmcp", "rpassword", "serde", @@ -482,6 +509,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1130,6 +1163,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1800,6 +1839,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2128,6 +2180,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index eb36195..7d887df 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -269,7 +269,9 @@ impl ChainBackend for KoiosClient { } async fn get_balance(&self, address: &str) -> Result { - let body = AddressesBody { addresses: vec![address] }; + let body = AddressesBody { + addresses: vec![address], + }; let raw: Vec = self.post_json("address_info", &body).await?; // Empty array = address has no on-chain history yet — treat @@ -338,11 +340,15 @@ impl ChainBackend for KoiosClient { } async fn tx_status(&self, tx_hash: &str) -> Result { - let body = TxHashesBody { tx_hashes: vec![tx_hash] }; + let body = TxHashesBody { + tx_hashes: vec![tx_hash], + }; let raw: Vec = self.post_json("tx_status", &body).await?; match raw.into_iter().next() { Some(info) => match info.num_confirmations { - Some(n) if n > 0 => Ok(TxStatus::Confirmed { num_confirmations: n }), + Some(n) if n > 0 => Ok(TxStatus::Confirmed { + num_confirmations: n, + }), Some(_) | None => Ok(TxStatus::Pending), }, None => Ok(TxStatus::NotFound), @@ -434,7 +440,11 @@ mod tests { #[test] fn deserializes_utxo_response() { let raw: Vec = serde_json::from_str(SAMPLE_UTXOS).unwrap(); - let utxos: Vec = raw.into_iter().map(convert_utxo).collect::>().unwrap(); + let utxos: Vec = raw + .into_iter() + .map(convert_utxo) + .collect::>() + .unwrap(); assert_eq!(utxos.len(), 2); assert_eq!(utxos[0].lovelace, 1_500_000); assert!(utxos[0].assets.is_empty()); @@ -530,7 +540,9 @@ mod tests { #[test] fn tx_status_serializes_with_tag() { - let confirmed = TxStatus::Confirmed { num_confirmations: 17 }; + let confirmed = TxStatus::Confirmed { + num_confirmations: 17, + }; let json = serde_json::to_string(&confirmed).unwrap(); assert!(json.contains("\"status\":\"confirmed\"")); assert!(json.contains("\"num_confirmations\":17")); @@ -582,6 +594,10 @@ mod tests { let result = client.get_balance(known_addr).await; // We don't assert a specific balance — just that the // request shape is valid and the response decodes. - assert!(result.is_ok(), "live balance call failed: {:?}", result.err()); + assert!( + result.is_ok(), + "live balance call failed: {:?}", + result.err() + ); } } diff --git a/crates/aldabra-core/src/cip68.rs b/crates/aldabra-core/src/cip68.rs index bb2132a..a196907 100644 --- a/crates/aldabra-core/src/cip68.rs +++ b/crates/aldabra-core/src/cip68.rs @@ -136,8 +136,7 @@ fn json_to_plutus_data(v: &Value) -> Result { Value::Object(map) => { let mut pairs: Vec<(PlutusData, PlutusData)> = Vec::with_capacity(map.len()); for (k, vv) in map { - let key = - PlutusData::BoundedBytes(BoundedBytes::from(k.as_bytes().to_vec())); + let key = PlutusData::BoundedBytes(BoundedBytes::from(k.as_bytes().to_vec())); let value = json_to_plutus_data(vv)?; pairs.push((key, value)); } @@ -165,9 +164,8 @@ pub fn build_cip68_datum_cbor(metadata: &Value) -> Result, WalletError> } let metadata_pd = json_to_plutus_data(metadata)?; - let version_pd = PlutusData::BigInt(BigInt::Int( - pallas_codec::utils::Int::from(CIP68_VERSION_2), - )); + let version_pd = + PlutusData::BigInt(BigInt::Int(pallas_codec::utils::Int::from(CIP68_VERSION_2))); // "extra" — Constr 0 with no fields (Plutus unit). let extra_pd = PlutusData::Constr(Constr { diff --git a/crates/aldabra-core/src/derive.rs b/crates/aldabra-core/src/derive.rs index 4e74d5b..7cd6ff1 100644 --- a/crates/aldabra-core/src/derive.rs +++ b/crates/aldabra-core/src/derive.rs @@ -99,10 +99,7 @@ impl StakeKey { /// Reward / stake address (`stake1...` or `stake_test1...`) /// bech32-encoded. This is the address you point at a stake pool /// when delegating. - pub fn stake_address( - &self, - network: crate::Network, - ) -> Result { + pub fn stake_address(&self, network: crate::Network) -> Result { use pallas_addresses::{StakeAddress, StakePayload}; let payload = StakePayload::Stake(self.public_key_hash()); let addr = StakeAddress::new(network.to_pallas(), payload); diff --git a/crates/aldabra-core/src/governance.rs b/crates/aldabra-core/src/governance.rs index 2cae9c1..65ff5a5 100644 --- a/crates/aldabra-core/src/governance.rs +++ b/crates/aldabra-core/src/governance.rs @@ -89,8 +89,8 @@ pub fn parse_drep_target(s: &str) -> Result { if s == "no_confidence" { return Ok(DRepTarget::NoConfidence); } - let (hrp, data, _) = bech32::decode(s) - .map_err(|e| WalletError::Address(format!("bad drep bech32: {e}")))?; + let (hrp, data, _) = + bech32::decode(s).map_err(|e| WalletError::Address(format!("bad drep bech32: {e}")))?; if hrp != "drep" && hrp != "drep_script" { return Err(WalletError::Address(format!( "expected drep / drep_script hrp, got '{hrp}'" @@ -139,8 +139,7 @@ pub fn parse_drep_target(s: &str) -> Result { } fn parse_address(bech32: &str) -> Result { - pallas_addresses::Address::from_bech32(bech32) - .map_err(|e| WalletError::Address(e.to_string())) + pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string())) } fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { @@ -425,18 +424,19 @@ fn sign_voting_tx( } let total_in: u64 = selected.iter().map(|u| u.lovelace).sum(); - let build_with_fee = |fee: u64, change_lovelace: u64| -> Result { - let mut staging = StagingTransaction::new(); - for u in &selected { - let h = parse_tx_hash(&u.tx_hash_hex)?; - staging = staging.input(Input::new(h, u.output_index as u64)); - } - let change_out = Output::new(change_addr.clone(), change_lovelace); - staging = staging.output(change_out); - staging = staging.voting_procedures(voting_procedures_cbor.clone()); - staging = staging.fee(fee).network_id(network_id); - Ok(staging) - }; + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); + } + let change_out = Output::new(change_addr.clone(), change_lovelace); + staging = staging.output(change_out); + staging = staging.voting_procedures(voting_procedures_cbor.clone()); + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; let change_pass1 = total_in .checked_sub(fee_pass1) @@ -450,11 +450,11 @@ fn sign_voting_tx( let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES; let real_fee = params.min_fee_for_size(est_signed); - let final_change = total_in - .checked_sub(real_fee) - .ok_or_else(|| WalletError::Derivation(format!( + let final_change = total_in.checked_sub(real_fee).ok_or_else(|| { + WalletError::Derivation(format!( "insufficient funds for fee: total_in={total_in} fee={real_fee}" - )))?; + )) + })?; if final_change < params.min_utxo_lovelace { return Err(WalletError::Derivation(format!( "change ({final_change}) below min utxo ({})", @@ -555,48 +555,49 @@ fn sign_cert_tx( } } - let build_with_fee = |fee: u64, - change_lovelace: u64| - -> Result { - let mut staging = StagingTransaction::new(); - for u in &selected { - let h = parse_tx_hash(&u.tx_hash_hex)?; - staging = staging.input(Input::new(h, u.output_index as u64)); - } - let mut change_out = Output::new(change_addr.clone(), change_lovelace); - for (k, q) in &input_assets { - if *q == 0 { - continue; + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); } - if k.len() < 56 { - return Err(WalletError::Derivation("asset key shorter than 56 chars".into())); + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &input_assets { + if *q == 0 { + continue; + } + if k.len() < 56 { + return Err(WalletError::Derivation( + "asset key shorter than 56 chars".into(), + )); + } + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let mut policy_bytes = [0u8; 28]; + for i in 0..28 { + policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16) + .map_err(|_| WalletError::Derivation("invalid policy hex".into()))?; + } + let policy = Hash::<28>::new(policy_bytes); + let mut name_bytes = Vec::with_capacity(name_hex.len() / 2); + for i in (0..name_hex.len()).step_by(2) { + name_bytes.push( + u8::from_str_radix(&name_hex[i..i + 2], 16) + .map_err(|_| WalletError::Derivation("invalid name hex".into()))?, + ); + } + change_out = change_out + .add_asset(policy, name_bytes, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; } - let pol_hex = &k[..56]; - let name_hex = &k[56..]; - let mut policy_bytes = [0u8; 28]; - for i in 0..28 { - policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16) - .map_err(|_| WalletError::Derivation("invalid policy hex".into()))?; + staging = staging.output(change_out); + for cb in &cert_bytes_list { + staging = staging.add_certificate(cb.clone()); } - let policy = Hash::<28>::new(policy_bytes); - let mut name_bytes = Vec::with_capacity(name_hex.len() / 2); - for i in (0..name_hex.len()).step_by(2) { - name_bytes.push( - u8::from_str_radix(&name_hex[i..i + 2], 16) - .map_err(|_| WalletError::Derivation("invalid name hex".into()))?, - ); - } - change_out = change_out - .add_asset(policy, name_bytes, *q) - .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; - } - staging = staging.output(change_out); - for cb in &cert_bytes_list { - staging = staging.add_certificate(cb.clone()); - } - staging = staging.fee(fee).network_id(network_id); - Ok(staging) - }; + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; let change_pass1 = total_in .checked_sub(deposit + fee_pass1) @@ -611,11 +612,11 @@ fn sign_cert_tx( let real_fee = params.min_fee_for_size(est_signed); let token_change = !input_assets.is_empty(); - let final_change = total_in - .checked_sub(deposit + real_fee) - .ok_or_else(|| WalletError::Derivation(format!( + let final_change = total_in.checked_sub(deposit + real_fee).ok_or_else(|| { + WalletError::Derivation(format!( "insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}" - )))?; + )) + })?; if final_change < params.min_utxo_lovelace && token_change { return Err(WalletError::Derivation(format!( "insufficient ADA for token-bearing change: change={final_change}, min={}", @@ -680,20 +681,21 @@ fn sign_cert_tx_with_refund( } let total_in: u64 = selected.iter().map(|u| u.lovelace).sum(); - let build_with_fee = |fee: u64, change_lovelace: u64| -> Result { - let mut staging = StagingTransaction::new(); - for u in &selected { - let h = parse_tx_hash(&u.tx_hash_hex)?; - staging = staging.input(Input::new(h, u.output_index as u64)); - } - let change_out = Output::new(change_addr.clone(), change_lovelace); - staging = staging.output(change_out); - for cb in &cert_bytes_list { - staging = staging.add_certificate(cb.clone()); - } - staging = staging.fee(fee).network_id(network_id); - Ok(staging) - }; + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); + } + let change_out = Output::new(change_addr.clone(), change_lovelace); + staging = staging.output(change_out); + for cb in &cert_bytes_list { + staging = staging.add_certificate(cb.clone()); + } + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; // Change includes the refund: change = total_in + refund - fee. let change_pass1 = total_in @@ -712,9 +714,11 @@ fn sign_cert_tx_with_refund( let final_change = total_in .checked_add(refund) .and_then(|x| x.checked_sub(real_fee)) - .ok_or_else(|| WalletError::Derivation(format!( - "insufficient funds for fee: total_in={total_in} refund={refund} fee={real_fee}" - )))?; + .ok_or_else(|| { + WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} refund={refund} fee={real_fee}" + )) + })?; if final_change < params.min_utxo_lovelace { return Err(WalletError::Derivation(format!( "change ({final_change}) below min utxo ({})", @@ -756,7 +760,10 @@ mod tests { fn drep_target_into_pallas_round_trip() { let h = Hash::<28>::new([0u8; 28]); assert!(matches!(DRepTarget::Key(h).into_pallas(), DRep::Key(_))); - assert!(matches!(DRepTarget::Script(h).into_pallas(), DRep::Script(_))); + assert!(matches!( + DRepTarget::Script(h).into_pallas(), + DRep::Script(_) + )); assert!(matches!(DRepTarget::Abstain.into_pallas(), DRep::Abstain)); assert!(matches!( DRepTarget::NoConfidence.into_pallas(), diff --git a/crates/aldabra-core/src/inspect.rs b/crates/aldabra-core/src/inspect.rs index d01bfa4..a2b1485 100644 --- a/crates/aldabra-core/src/inspect.rs +++ b/crates/aldabra-core/src/inspect.rs @@ -243,10 +243,8 @@ pub fn summarize_tx(cbor_bytes: &[u8]) -> Result { .unwrap_or(0); let auxiliary_data_hash_set = body.auxiliary_data_hash.is_some(); - let auxiliary_data_present = matches!( - tx.auxiliary_data, - pallas_codec::utils::Nullable::Some(_) - ); + let auxiliary_data_present = + matches!(tx.auxiliary_data, pallas_codec::utils::Nullable::Some(_)); Ok(TxSummary { tx_hash: hex(body_hash.as_ref()), diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 209b92f..6431c12 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -47,18 +47,23 @@ pub mod plutus_mint; pub mod sign; pub mod stake; pub mod tx; -pub use cip68::{ - build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name, -}; +pub use cip68::{build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name}; pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey}; -pub use inspect::{summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary}; +pub use inspect::{ + summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary, +}; // Stake address derivation lives directly on StakeKey — exported above. +pub use governance::{ + build_signed_drep_deregistration, build_signed_drep_registration, build_signed_drep_vote_cast, + build_signed_vote_delegation, parse_drep_target, DRepTarget, VoteChoice, + DREP_REGISTRATION_DEPOSIT_LOVELACE, +}; pub use metadata::{build_cip25_aux_data, CIP25_LABEL}; pub use mint::{ build_signed_cip68_nft_mint, build_signed_mint, build_signed_mint_with_metadata, build_unsigned_mint, PolicySpec, }; -pub use sign::add_witness; +pub use pallas_txbuilder::ScriptKind; pub use plutus::{ build_signed_plutus_spend, looks_like_script_address, PlutusExUnits, PlutusInput, PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE, @@ -67,19 +72,14 @@ pub use plutus_mint::{ build_signed_plutus_mint, build_unsigned_plutus_mint, ExtraDestAsset, PlutusMintArgs, PlutusMintAsset, }; +pub use sign::add_witness; pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE}; -pub use governance::{ - build_signed_drep_deregistration, build_signed_drep_registration, - build_signed_drep_vote_cast, build_signed_vote_delegation, parse_drep_target, - DRepTarget, VoteChoice, DREP_REGISTRATION_DEPOSIT_LOVELACE, -}; pub use tx::{ build_signed_payment, build_signed_payment_extras, build_signed_payment_with_assets, build_unsigned_payment, build_unsigned_payment_extras, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary, ProtocolParams, ReferenceScriptSpec, UnsignedPayment, }; -pub use pallas_txbuilder::ScriptKind; #[derive(Debug, Error)] pub enum WalletError { @@ -171,10 +171,7 @@ impl Mnemonic { /// 2. Bit-clamp the first 32 bytes so the result is a valid extended /// Ed25519 scalar with the 3rd-highest bit cleared /// (`normalize_bytes_force3rd`). - pub fn into_root_key_with_passphrase( - self, - passphrase: &str, - ) -> Result { + pub fn into_root_key_with_passphrase(self, passphrase: &str) -> Result { let mut xprv_bytes = [0u8; XPRV_SIZE]; let mut hmac = Hmac::new(Sha512::new(), passphrase.as_bytes()); pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes); @@ -420,8 +417,8 @@ mod tests { assert!(addr.starts_with("addr1"), "got: {addr}"); // Round-trip — pallas should parse what we just emitted and // give back a Shelley mainnet address. - let parsed = pallas_addresses::Address::from_bech32(&addr) - .expect("our own bech32 output parses"); + let parsed = + pallas_addresses::Address::from_bech32(&addr).expect("our own bech32 output parses"); match parsed { pallas_addresses::Address::Shelley(s) => { assert_eq!(s.network(), pallas_addresses::Network::Mainnet); diff --git a/crates/aldabra-core/src/metadata.rs b/crates/aldabra-core/src/metadata.rs index 4dfe1a7..0886943 100644 --- a/crates/aldabra-core/src/metadata.rs +++ b/crates/aldabra-core/src/metadata.rs @@ -74,7 +74,11 @@ fn json_to_metadatum(v: &Value) -> Result { Value::Null => Err(WalletError::Derivation( "null is not representable in Cardano metadata".into(), )), - Value::Bool(b) => Ok(Metadatum::Int(Int(CborInt::from(if *b { 1i64 } else { 0 })))), + Value::Bool(b) => Ok(Metadatum::Int(Int(CborInt::from(if *b { + 1i64 + } else { + 0 + })))), Value::Number(n) => { let i = n.as_i64().ok_or_else(|| { WalletError::Derivation(format!( @@ -166,8 +170,7 @@ pub fn build_cip25_aux_data( plutus_scripts: None, }); - minicbor::to_vec(&aux) - .map_err(|e| WalletError::Derivation(format!("encode CIP-25 aux: {e}"))) + minicbor::to_vec(&aux).map_err(|e| WalletError::Derivation(format!("encode CIP-25 aux: {e}"))) } fn decode_hex(s: &str) -> Result, WalletError> { diff --git a/crates/aldabra-core/src/mint.rs b/crates/aldabra-core/src/mint.rs index 58a6985..02add92 100644 --- a/crates/aldabra-core/src/mint.rs +++ b/crates/aldabra-core/src/mint.rs @@ -32,7 +32,9 @@ use pallas_codec::minicbor; use pallas_crypto::hash::Hash; use pallas_primitives::alonzo::NativeScript; use pallas_traverse::ComputeHash; -use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction}; +use pallas_txbuilder::{ + BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction, +}; use pallas_wallet::PrivateKey; use serde::{Deserialize, Serialize}; @@ -161,8 +163,7 @@ fn hash_to_hex(h: &Hash<28>) -> String { } fn parse_address(bech32: &str) -> Result { - pallas_addresses::Address::from_bech32(bech32) - .map_err(|e| WalletError::Address(e.to_string())) + pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string())) } fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { @@ -353,11 +354,9 @@ fn prepare_mint( (real_fee, c) } Some(c) => (real_fee + c, 0), - None => { - return Err(WalletError::Derivation(format!( - "insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}" - ))) - } + None => return Err(WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}" + ))), }; let staging2 = build_mint_staging( @@ -729,63 +728,62 @@ pub fn build_signed_cip68_nft_mint( } } - let build_with_fee = |fee: u64, - change_lovelace: u64| - -> Result { - let mut staging = StagingTransaction::new(); - for u in &selected { - let h = parse_tx_hash(&u.tx_hash_hex)?; - staging = staging.input(Input::new(h, u.output_index as u64)); - } - - // Output 1: ref NFT @ ref_address, with inline datum. - let ref_out = Output::new(ref_addr.clone(), ref_lovelace) - .add_asset(policy_id, ref_name.clone(), 1) - .map_err(|e| WalletError::Derivation(format!("ref add_asset: {e}")))? - .set_inline_datum(datum_cbor.clone()); - staging = staging.output(ref_out); - - // Output 2: user NFT @ user_address. - let user_out = Output::new(user_addr.clone(), user_lovelace) - .add_asset(policy_id, user_name.clone(), 1) - .map_err(|e| WalletError::Derivation(format!("user add_asset: {e}")))?; - staging = staging.output(user_out); - - // Output 3 (optional): change @ wallet, with leftover input assets. - let nonzero_change_assets: std::collections::BTreeMap = input_assets - .iter() - .filter(|(_, q)| **q > 0) - .map(|(k, v)| (k.clone(), *v)) - .collect(); - if change_lovelace > 0 || !nonzero_change_assets.is_empty() { - let mut change_out = Output::new(change_addr.clone(), change_lovelace); - for (k, q) in &nonzero_change_assets { - if k.len() < 56 { - return Err(WalletError::Derivation( - "change asset key shorter than 56 chars".into(), - )); - } - let p = parse_pkh(&k[..56])?; - let n = parse_asset_name(&k[56..])?; - change_out = change_out - .add_asset(p, n, *q) - .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); } - staging = staging.output(change_out); - } - staging = staging - .mint_asset(policy_id, ref_name.clone(), 1) - .map_err(|e| WalletError::Derivation(format!("mint ref: {e}")))? - .mint_asset(policy_id, user_name.clone(), 1) - .map_err(|e| WalletError::Derivation(format!("mint user: {e}")))? - .script(ScriptKind::Native, script_cbor.clone()) - .disclosed_signer(payment_pkh) - .fee(fee) - .network_id(network_id); + // Output 1: ref NFT @ ref_address, with inline datum. + let ref_out = Output::new(ref_addr.clone(), ref_lovelace) + .add_asset(policy_id, ref_name.clone(), 1) + .map_err(|e| WalletError::Derivation(format!("ref add_asset: {e}")))? + .set_inline_datum(datum_cbor.clone()); + staging = staging.output(ref_out); - Ok(staging) - }; + // Output 2: user NFT @ user_address. + let user_out = Output::new(user_addr.clone(), user_lovelace) + .add_asset(policy_id, user_name.clone(), 1) + .map_err(|e| WalletError::Derivation(format!("user add_asset: {e}")))?; + staging = staging.output(user_out); + + // Output 3 (optional): change @ wallet, with leftover input assets. + let nonzero_change_assets: std::collections::BTreeMap = input_assets + .iter() + .filter(|(_, q)| **q > 0) + .map(|(k, v)| (k.clone(), *v)) + .collect(); + if change_lovelace > 0 || !nonzero_change_assets.is_empty() { + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &nonzero_change_assets { + if k.len() < 56 { + return Err(WalletError::Derivation( + "change asset key shorter than 56 chars".into(), + )); + } + let p = parse_pkh(&k[..56])?; + let n = parse_asset_name(&k[56..])?; + change_out = change_out + .add_asset(p, n, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + } + staging = staging.output(change_out); + } + + staging = staging + .mint_asset(policy_id, ref_name.clone(), 1) + .map_err(|e| WalletError::Derivation(format!("mint ref: {e}")))? + .mint_asset(policy_id, user_name.clone(), 1) + .map_err(|e| WalletError::Derivation(format!("mint user: {e}")))? + .script(ScriptKind::Native, script_cbor.clone()) + .disclosed_signer(payment_pkh) + .fee(fee) + .network_id(network_id); + + Ok(staging) + }; // Pass 1 — placeholder fee, measure unsigned size, recompute. let change_pass1 = total_in @@ -803,25 +801,24 @@ pub fn build_signed_cip68_nft_mint( let real_fee = params.min_fee_for_size(est_signed); let token_change = !input_assets.is_empty(); - let (final_fee, final_change) = match total_in - .checked_sub(user_lovelace + ref_lovelace + real_fee) - { - Some(c) if c >= params.min_utxo_lovelace || token_change => { - if token_change && c < params.min_utxo_lovelace { - return Err(WalletError::Derivation(format!( - "insufficient ADA for token-bearing change: change={c}, min={}", - params.min_utxo_lovelace - ))); + let (final_fee, final_change) = + match total_in.checked_sub(user_lovelace + ref_lovelace + real_fee) { + Some(c) if c >= params.min_utxo_lovelace || token_change => { + if token_change && c < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "insufficient ADA for token-bearing change: change={c}, min={}", + params.min_utxo_lovelace + ))); + } + (real_fee, c) } - (real_fee, c) - } - Some(c) => (real_fee + c, 0), - None => { - return Err(WalletError::Derivation(format!( - "insufficient funds for fee: total_in={total_in} fee={real_fee}" - ))) - } - }; + Some(c) => (real_fee + c, 0), + None => { + return Err(WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} fee={real_fee}" + ))) + } + }; let staging2 = build_with_fee(final_fee, final_change)?; let built = staging2 @@ -949,7 +946,11 @@ mod tests { &ProtocolParams::default(), ) .expect("mint builds + signs"); - assert!(cbor.len() > 200, "mint cbor too short: {} bytes", cbor.len()); + assert!( + cbor.len() > 200, + "mint cbor too short: {} bytes", + cbor.len() + ); } #[test] @@ -1047,7 +1048,10 @@ mod tests { break; } } - assert!(found_inline_datum, "ref NFT output must carry an inline datum"); + assert!( + found_inline_datum, + "ref NFT output must carry an inline datum" + ); } #[test] @@ -1085,8 +1089,8 @@ mod tests { // Decode the resulting tx and confirm: // 1. aux_data is present // 2. body.auxiliary_data_hash is populated - let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor) - .expect("decode signed mint cbor"); + let tx = + pallas_primitives::conway::Tx::decode_fragment(&cbor).expect("decode signed mint cbor"); assert!( tx.transaction_body.auxiliary_data_hash.is_some(), "aux_data_hash must be set when metadata is attached" diff --git a/crates/aldabra-core/src/plutus.rs b/crates/aldabra-core/src/plutus.rs index 3b46743..be12662 100644 --- a/crates/aldabra-core/src/plutus.rs +++ b/crates/aldabra-core/src/plutus.rs @@ -75,8 +75,7 @@ impl PlutusVersion { pub const MIN_COLLATERAL_LOVELACE: u64 = 5_000_000; fn parse_address(bech32: &str) -> Result { - pallas_addresses::Address::from_bech32(bech32) - .map_err(|e| WalletError::Address(e.to_string())) + pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string())) } fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { @@ -182,8 +181,7 @@ pub fn build_signed_plutus_spend( let funding = ada_only .iter() .find(|u| { - !(u.tx_hash_hex == collateral.tx_hash_hex - && u.output_index == collateral.output_index) + !(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index) }) .cloned() .ok_or_else(|| { @@ -225,46 +223,43 @@ pub fn build_signed_plutus_spend( collateral.output_index as u64, ); - let build_with_fee = |fee: u64, - change_lovelace: u64| - -> Result { - let mut staging = StagingTransaction::new(); - // PLUTUS-1: locked + funding as regular inputs (both consumed - // on happy path); collateral as collateral_input only. - staging = staging.input(locked_input.clone()); - staging = staging.input(funding_input.clone()); - staging = staging.collateral_input(collateral_input.clone()); - staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace)); - if change_lovelace > 0 { - staging = staging.output(Output::new(change_addr.clone(), change_lovelace)); - } - staging = staging - .script(script_version.to_script_kind(), script_cbor.to_vec()) - .add_spend_redeemer( - locked_input.clone(), - redeemer_cbor.to_vec(), - Some(ex_units.into()), - ) - .fee(fee) - .network_id(network_id); - if let Some(d) = witness_datum_cbor { - staging = staging.datum(d.to_vec()); - } - // PLUTUS-4 audit fix: pallas-txbuilder only computes - // script_data_hash if language_view is set. Without it, the - // body's hash is None and the chain rejects with - // PPViewHashesDontMatch. PlutusV3 path requires a V3 cost - // model — caller-supplied via ProtocolParams. - if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() { - if matches!(script_version, PlutusVersion::V3) { - staging = staging.language_view( - script_version.to_script_kind(), - cost_model.to_vec(), - ); + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + // PLUTUS-1: locked + funding as regular inputs (both consumed + // on happy path); collateral as collateral_input only. + staging = staging.input(locked_input.clone()); + staging = staging.input(funding_input.clone()); + staging = staging.collateral_input(collateral_input.clone()); + staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace)); + if change_lovelace > 0 { + staging = staging.output(Output::new(change_addr.clone(), change_lovelace)); } - } - Ok(staging) - }; + staging = staging + .script(script_version.to_script_kind(), script_cbor.to_vec()) + .add_spend_redeemer( + locked_input.clone(), + redeemer_cbor.to_vec(), + Some(ex_units.into()), + ) + .fee(fee) + .network_id(network_id); + if let Some(d) = witness_datum_cbor { + staging = staging.datum(d.to_vec()); + } + // PLUTUS-4 audit fix: pallas-txbuilder only computes + // script_data_hash if language_view is set. Without it, the + // body's hash is None and the chain rejects with + // PPViewHashesDontMatch. PlutusV3 path requires a V3 cost + // model — caller-supplied via ProtocolParams. + if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() { + if matches!(script_version, PlutusVersion::V3) { + staging = + staging.language_view(script_version.to_script_kind(), cost_model.to_vec()); + } + } + Ok(staging) + }; // Pass 1 — placeholder fee, measure unsigned size. let change_pass1 = total_in @@ -316,7 +311,10 @@ pub fn looks_like_script_address(addr_bech32: &str) -> bool { // is a script-payment + key-delegation address; types 2, // 3, 5, 7 also have script payment parts. Matches header // bits where bit 4 = 1 for script payment. - bytes.first().map(|b| (b >> 4) & 0b0001 != 0).unwrap_or(false) + bytes + .first() + .map(|b| (b >> 4) & 0b0001 != 0) + .unwrap_or(false) }) .unwrap_or(false) } diff --git a/crates/aldabra-core/src/plutus_cost_models.rs b/crates/aldabra-core/src/plutus_cost_models.rs index ed8c4e2..4bca70f 100644 --- a/crates/aldabra-core/src/plutus_cost_models.rs +++ b/crates/aldabra-core/src/plutus_cost_models.rs @@ -13,66 +13,34 @@ // Naming kept as `_PREPROD` for git churn reasons; treat as // "current Plutus V3 protocol parameters." pub const PLUTUS_V3_COST_MODEL_PREPROD: [i64; 297] = [ - 100788, 420, 1, 1, 1000, 173, 0, 1, - 1000, 59957, 4, 1, 11183, 32, 201305, 8356, - 4, 16000, 100, 16000, 100, 16000, 100, 16000, - 100, 16000, 100, 16000, 100, 100, 100, 16000, - 100, 94375, 32, 132994, 32, 61462, 4, 72010, - 178, 0, 1, 22151, 32, 91189, 769, 4, - 2, 85848, 123203, 7305, -900, 1716, 549, 57, - 85848, 0, 1, 1, 1000, 42921, 4, 2, - 24548, 29498, 38, 1, 898148, 27279, 1, 51775, - 558, 1, 39184, 1000, 60594, 1, 141895, 32, - 83150, 32, 15299, 32, 76049, 1, 13169, 4, - 22100, 10, 28999, 74, 1, 28999, 74, 1, - 43285, 552, 1, 44749, 541, 1, 33852, 32, - 68246, 32, 72362, 32, 7243, 32, 7391, 32, - 11546, 32, 85848, 123203, 7305, -900, 1716, 549, - 57, 85848, 0, 1, 90434, 519, 0, 1, - 74433, 32, 85848, 123203, 7305, -900, 1716, 549, - 57, 85848, 0, 1, 1, 85848, 123203, 7305, - -900, 1716, 549, 57, 85848, 0, 1, 955506, - 213312, 0, 2, 270652, 22588, 4, 1457325, 64566, - 4, 20467, 1, 4, 0, 141992, 32, 100788, - 420, 1, 1, 81663, 32, 59498, 32, 20142, - 32, 24588, 32, 20744, 32, 25933, 32, 24623, - 32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308, - 10, 16000, 100, 16000, 100, 962335, 18, 2780678, - 6, 442008, 1, 52538055, 3756, 18, 267929, 18, - 76433006, 8868, 18, 52948122, 18, 1995836, 36, 3227919, - 12, 901022, 1, 166917843, 4307, 36, 284546, 36, - 158221314, 26549, 36, 74698472, 36, 333849714, 1, 254006273, - 72, 2174038, 72, 2261318, 64571, 4, 207616, 8310, - 4, 1293828, 28716, 63, 0, 1, 1006041, 43623, - 251, 0, 1, 100181, 726, 719, 0, 1, - 100181, 726, 719, 0, 1, 100181, 726, 719, - 0, 1, 107878, 680, 0, 1, 95336, 1, - 281145, 18848, 0, 1, 180194, 159, 1, 1, - 158519, 8942, 0, 1, 159378, 8813, 0, 1, - 107490, 3298, 1, 106057, 655, 1, 1964219, 24520, - 3, + 100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, 16000, 100, + 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, 16000, 100, 94375, 32, + 132994, 32, 61462, 4, 72010, 178, 0, 1, 22151, 32, 91189, 769, 4, 2, 85848, 123203, 7305, -900, + 1716, 549, 57, 85848, 0, 1, 1, 1000, 42921, 4, 2, 24548, 29498, 38, 1, 898148, 27279, 1, 51775, + 558, 1, 39184, 1000, 60594, 1, 141895, 32, 83150, 32, 15299, 32, 76049, 1, 13169, 4, 22100, 10, + 28999, 74, 1, 28999, 74, 1, 43285, 552, 1, 44749, 541, 1, 33852, 32, 68246, 32, 72362, 32, + 7243, 32, 7391, 32, 11546, 32, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 90434, + 519, 0, 1, 74433, 32, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 1, 85848, 123203, + 7305, -900, 1716, 549, 57, 85848, 0, 1, 955506, 213312, 0, 2, 270652, 22588, 4, 1457325, 64566, + 4, 20467, 1, 4, 0, 141992, 32, 100788, 420, 1, 1, 81663, 32, 59498, 32, 20142, 32, 24588, 32, + 20744, 32, 25933, 32, 24623, 32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308, 10, 16000, + 100, 16000, 100, 962335, 18, 2780678, 6, 442008, 1, 52538055, 3756, 18, 267929, 18, 76433006, + 8868, 18, 52948122, 18, 1995836, 36, 3227919, 12, 901022, 1, 166917843, 4307, 36, 284546, 36, + 158221314, 26549, 36, 74698472, 36, 333849714, 1, 254006273, 72, 2174038, 72, 2261318, 64571, + 4, 207616, 8310, 4, 1293828, 28716, 63, 0, 1, 1006041, 43623, 251, 0, 1, 100181, 726, 719, 0, + 1, 100181, 726, 719, 0, 1, 100181, 726, 719, 0, 1, 107878, 680, 0, 1, 95336, 1, 281145, 18848, + 0, 1, 180194, 159, 1, 1, 158519, 8942, 0, 1, 159378, 8813, 0, 1, 107490, 3298, 1, 106057, 655, + 1, 1964219, 24520, 3, ]; pub const PLUTUS_V2_COST_MODEL_PREPROD: [i64; 175] = [ - 100788, 420, 1, 1, 1000, 173, 0, 1, - 1000, 59957, 4, 1, 11183, 32, 201305, 8356, - 4, 16000, 100, 16000, 100, 16000, 100, 16000, - 100, 16000, 100, 16000, 100, 100, 100, 16000, - 100, 94375, 32, 132994, 32, 61462, 4, 72010, - 178, 0, 1, 22151, 32, 91189, 769, 4, - 2, 85848, 228465, 122, 0, 1, 1, 1000, - 42921, 4, 2, 24548, 29498, 38, 1, 898148, - 27279, 1, 51775, 558, 1, 39184, 1000, 60594, - 1, 141895, 32, 83150, 32, 15299, 32, 76049, - 1, 13169, 4, 22100, 10, 28999, 74, 1, - 28999, 74, 1, 43285, 552, 1, 44749, 541, - 1, 33852, 32, 68246, 32, 72362, 32, 7243, - 32, 7391, 32, 11546, 32, 85848, 228465, 122, - 0, 1, 1, 90434, 519, 0, 1, 74433, - 32, 85848, 228465, 122, 0, 1, 1, 85848, - 228465, 122, 0, 1, 1, 955506, 213312, 0, - 2, 270652, 22588, 4, 1457325, 64566, 4, 20467, - 1, 4, 0, 141992, 32, 100788, 420, 1, - 1, 81663, 32, 59498, 32, 20142, 32, 24588, - 32, 20744, 32, 25933, 32, 24623, 32, 43053543, - 10, 53384111, 14333, 10, 43574283, 26308, 10, + 100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, 16000, 100, + 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, 16000, 100, 94375, 32, + 132994, 32, 61462, 4, 72010, 178, 0, 1, 22151, 32, 91189, 769, 4, 2, 85848, 228465, 122, 0, 1, + 1, 1000, 42921, 4, 2, 24548, 29498, 38, 1, 898148, 27279, 1, 51775, 558, 1, 39184, 1000, 60594, + 1, 141895, 32, 83150, 32, 15299, 32, 76049, 1, 13169, 4, 22100, 10, 28999, 74, 1, 28999, 74, 1, + 43285, 552, 1, 44749, 541, 1, 33852, 32, 68246, 32, 72362, 32, 7243, 32, 7391, 32, 11546, 32, + 85848, 228465, 122, 0, 1, 1, 90434, 519, 0, 1, 74433, 32, 85848, 228465, 122, 0, 1, 1, 85848, + 228465, 122, 0, 1, 1, 955506, 213312, 0, 2, 270652, 22588, 4, 1457325, 64566, 4, 20467, 1, 4, + 0, 141992, 32, 100788, 420, 1, 1, 81663, 32, 59498, 32, 20142, 32, 24588, 32, 20744, 32, 25933, + 32, 24623, 32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308, 10, ]; diff --git a/crates/aldabra-core/src/plutus_mint.rs b/crates/aldabra-core/src/plutus_mint.rs index e7b6da6..c04444f 100644 --- a/crates/aldabra-core/src/plutus_mint.rs +++ b/crates/aldabra-core/src/plutus_mint.rs @@ -250,14 +250,13 @@ pub fn build_unsigned_plutus_mint( params, )?; Ok(crate::tx::UnsignedPayment { - cbor_hex: built - .tx_bytes - .0 - .iter() - .fold(String::with_capacity(built.tx_bytes.0.len() * 2), |mut s, b| { + cbor_hex: built.tx_bytes.0.iter().fold( + String::with_capacity(built.tx_bytes.0.len() * 2), + |mut s, b| { s.push_str(&format!("{:02x}", b)); s - }), + }, + ), summary, }) } @@ -380,11 +379,8 @@ fn prepare_plutus_mint( let mut held: std::collections::BTreeMap = Default::default(); for u in &funding { for (k, v) in &u.assets { - *held.entry(k.clone()).or_insert(0) = held - .get(k) - .copied() - .unwrap_or(0) - .saturating_add(*v); + *held.entry(k.clone()).or_insert(0) = + held.get(k).copied().unwrap_or(0).saturating_add(*v); } } // Pull in UTxOs that contribute the needed assets. @@ -406,11 +402,8 @@ fn prepare_plutus_mint( } if helps { for (k, v) in &u.assets { - *held.entry(k.clone()).or_insert(0) = held - .get(k) - .copied() - .unwrap_or(0) - .saturating_add(*v); + *held.entry(k.clone()).or_insert(0) = + held.get(k).copied().unwrap_or(0).saturating_add(*v); } funding.push(u.clone()); } @@ -465,11 +458,8 @@ fn prepare_plutus_mint( let mut input_assets: std::collections::BTreeMap = Default::default(); for u in &funding { for (k, v) in &u.assets { - *input_assets.entry(k.clone()).or_insert(0) = input_assets - .get(k) - .copied() - .unwrap_or(0) - .saturating_add(*v); + *input_assets.entry(k.clone()).or_insert(0) = + input_assets.get(k).copied().unwrap_or(0).saturating_add(*v); } } @@ -502,11 +492,8 @@ fn prepare_plutus_mint( } } for (k, q) in &needed_extras { - *dest_assets.entry(k.clone()).or_insert(0) = dest_assets - .get(k) - .copied() - .unwrap_or(0) - .saturating_add(*q); + *dest_assets.entry(k.clone()).or_insert(0) = + dest_assets.get(k).copied().unwrap_or(0).saturating_add(*q); } // Change assets = input_assets minus dest extras (mint doesn't @@ -529,116 +516,122 @@ fn prepare_plutus_mint( let funding_inputs: Vec = funding .iter() .map(|u| -> Result<_, WalletError> { - Ok(Input::new(parse_tx_hash(&u.tx_hash_hex)?, u.output_index as u64)) + Ok(Input::new( + parse_tx_hash(&u.tx_hash_hex)?, + u.output_index as u64, + )) }) .collect::>()?; - let build_with_fee = |fee: u64, - change_lovelace: u64| - -> Result { - let mut staging = StagingTransaction::new(); - for inp in &funding_inputs { - staging = staging.input(inp.clone()); - } - staging = staging.collateral_input(collateral_input.clone()); - - // Dest output. - let mut dest_out = Output::new(dest_addr.clone(), args.dest_lovelace); - for (k, q) in &dest_assets { - if *q == 0 { - continue; + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for inp in &funding_inputs { + staging = staging.input(inp.clone()); } - let pol_hex = &k[..56]; - let name_hex = &k[56..]; - let p = parse_policy_id(pol_hex)?; - let n = parse_asset_name(name_hex)?; - dest_out = dest_out - .add_asset(p, n, *q) - .map_err(|e| WalletError::Derivation(format!("dest add_asset: {e}")))?; - } - if let Some(d) = args.dest_inline_datum_cbor { - dest_out = dest_out.set_inline_datum(d.to_vec()); - } - staging = staging.output(dest_out); + staging = staging.collateral_input(collateral_input.clone()); - // Change output (only if needed). - let nonzero_change: std::collections::BTreeMap = change_assets - .iter() - .filter(|(_, q)| **q > 0) - .map(|(k, v)| (k.clone(), *v)) - .collect(); - if change_lovelace > 0 || !nonzero_change.is_empty() { - let mut change_out = Output::new(change_addr.clone(), change_lovelace); - for (k, q) in &nonzero_change { + // Dest output. + let mut dest_out = Output::new(dest_addr.clone(), args.dest_lovelace); + for (k, q) in &dest_assets { + if *q == 0 { + continue; + } let pol_hex = &k[..56]; let name_hex = &k[56..]; let p = parse_policy_id(pol_hex)?; let n = parse_asset_name(name_hex)?; - change_out = change_out + dest_out = dest_out .add_asset(p, n, *q) - .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + .map_err(|e| WalletError::Derivation(format!("dest add_asset: {e}")))?; } - staging = staging.output(change_out); - } + if let Some(d) = args.dest_inline_datum_cbor { + dest_out = dest_out.set_inline_datum(d.to_vec()); + } + staging = staging.output(dest_out); - // Mint each asset. - for (name_bytes, qty) in &parsed_mint_assets { + // Change output (only if needed). + let nonzero_change: std::collections::BTreeMap = change_assets + .iter() + .filter(|(_, q)| **q > 0) + .map(|(k, v)| (k.clone(), *v)) + .collect(); + if change_lovelace > 0 || !nonzero_change.is_empty() { + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &nonzero_change { + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let p = parse_policy_id(pol_hex)?; + let n = parse_asset_name(name_hex)?; + change_out = change_out + .add_asset(p, n, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + } + staging = staging.output(change_out); + } + + // Mint each asset. + for (name_bytes, qty) in &parsed_mint_assets { + staging = staging + .mint_asset(policy_hash, name_bytes.clone(), *qty) + .map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))?; + } + + // Inline policy script witness + redeemer. + let kind: ScriptKind = match args.policy_version { + PlutusVersion::V1 => ScriptKind::PlutusV1, + PlutusVersion::V2 => ScriptKind::PlutusV2, + PlutusVersion::V3 => ScriptKind::PlutusV3, + }; staging = staging - .mint_asset(policy_hash, name_bytes.clone(), *qty) - .map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))?; - } + .script(kind, args.policy_cbor.to_vec()) + .add_mint_redeemer( + policy_hash, + args.redeemer_cbor.to_vec(), + Some(args.ex_units.into()), + ) + .fee(fee) + .network_id(network_id); - // Inline policy script witness + redeemer. - let kind: ScriptKind = match args.policy_version { - PlutusVersion::V1 => ScriptKind::PlutusV1, - PlutusVersion::V2 => ScriptKind::PlutusV2, - PlutusVersion::V3 => ScriptKind::PlutusV3, - }; - staging = staging - .script(kind, args.policy_cbor.to_vec()) - .add_mint_redeemer( - policy_hash, - args.redeemer_cbor.to_vec(), - Some(args.ex_units.into()), - ) - .fee(fee) - .network_id(network_id); - - for pkh in args.additional_signers { - staging = staging.disclosed_signer(*pkh); - } - - // Plutus V1/V2/V3 each need their cost-model wired via - // language_view so pallas computes script_data_hash on the tx - // body. Without it, chain rejects with PPViewHashesDontMatch. - // Caught 2026-05-07 attempting Agora's V2 GST-policy bootstrap - // mint on preprod — earlier code only set language_view for - // V3 and every V2 mint hit the chain rejection. - match args.policy_version { - PlutusVersion::V2 => { - staging = staging.language_view( - kind, - crate::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(), - ); + for pkh in args.additional_signers { + staging = staging.disclosed_signer(*pkh); } - PlutusVersion::V3 => { - if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() { - staging = staging.language_view(kind, cost_model.to_vec()); + + // Plutus V1/V2/V3 each need their cost-model wired via + // language_view so pallas computes script_data_hash on the tx + // body. Without it, chain rejects with PPViewHashesDontMatch. + // Caught 2026-05-07 attempting Agora's V2 GST-policy bootstrap + // mint on preprod — earlier code only set language_view for + // V3 and every V2 mint hit the chain rejection. + match args.policy_version { + PlutusVersion::V2 => { + staging = staging.language_view( + kind, + crate::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(), + ); + } + PlutusVersion::V3 => { + if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() { + staging = staging.language_view(kind, cost_model.to_vec()); + } + } + PlutusVersion::V1 => { + // V1 cost model not yet provided in aldabra-core. If a + // V1 mint is ever needed, append PLUTUS_V1_COST_MODEL_PREPROD + // to plutus_cost_models.rs and add the matching arm here. } } - PlutusVersion::V1 => { - // V1 cost model not yet provided in aldabra-core. If a - // V1 mint is ever needed, append PLUTUS_V1_COST_MODEL_PREPROD - // to plutus_cost_models.rs and add the matching arm here. - } - } - Ok(staging) - }; + Ok(staging) + }; // Pass 1. let token_change = !change_assets.values().all(|v| *v == 0); - let need_change_min = if token_change { params.min_utxo_lovelace } else { 0 }; + let need_change_min = if token_change { + params.min_utxo_lovelace + } else { + 0 + }; let change_pass1 = total_in .checked_sub(args.dest_lovelace.saturating_add(fee_pass1)) .filter(|c| *c >= need_change_min) diff --git a/crates/aldabra-core/src/sign.rs b/crates/aldabra-core/src/sign.rs index 42f2f65..03ddc5e 100644 --- a/crates/aldabra-core/src/sign.rs +++ b/crates/aldabra-core/src/sign.rs @@ -34,10 +34,7 @@ use crate::{PaymentKey, WalletError}; /// Append a vkeywitness from the given payment key to an existing /// (unsigned or partially-signed) Conway tx. Returns the new CBOR. -pub fn add_witness( - payment_key: &PaymentKey, - cbor_bytes: &[u8], -) -> Result, WalletError> { +pub fn add_witness(payment_key: &PaymentKey, cbor_bytes: &[u8]) -> Result, WalletError> { let mut tx = Tx::decode_fragment(cbor_bytes) .map_err(|e| WalletError::Derivation(format!("decode tx: {e}")))?; @@ -79,8 +76,8 @@ pub fn add_witness( witnesses.push(new_witness); tx.transaction_witness_set.vkeywitness = NonEmptySet::from_vec(witnesses); - let encoded = minicbor::to_vec(&tx) - .map_err(|e| WalletError::Derivation(format!("encode tx: {e}")))?; + let encoded = + minicbor::to_vec(&tx).map_err(|e| WalletError::Derivation(format!("encode tx: {e}")))?; Ok(encoded) } diff --git a/crates/aldabra-core/src/stake.rs b/crates/aldabra-core/src/stake.rs index 8bdd892..8ee8a1e 100644 --- a/crates/aldabra-core/src/stake.rs +++ b/crates/aldabra-core/src/stake.rs @@ -52,8 +52,7 @@ pub fn parse_pool_id(bech32_str: &str) -> Result, WalletError> { } fn parse_address(bech32: &str) -> Result { - pallas_addresses::Address::from_bech32(bech32) - .map_err(|e| WalletError::Address(e.to_string())) + pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string())) } fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { @@ -156,50 +155,51 @@ pub fn build_signed_stake_delegation( } } - let build_with_fee = |fee: u64, - change_lovelace: u64| - -> Result { - let mut staging = StagingTransaction::new(); - for u in &selected { - let h = parse_tx_hash(&u.tx_hash_hex)?; - staging = staging.input(Input::new(h, u.output_index as u64)); - } - let mut change_out = Output::new(change_addr.clone(), change_lovelace); - for (k, q) in &input_assets { - if *q == 0 { - continue; + let build_with_fee = + |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); } - if k.len() < 56 { - return Err(WalletError::Derivation( - "asset key shorter than 56 chars".into(), - )); + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &input_assets { + if *q == 0 { + continue; + } + if k.len() < 56 { + return Err(WalletError::Derivation( + "asset key shorter than 56 chars".into(), + )); + } + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let mut policy_bytes = [0u8; 28]; + for i in 0..28 { + policy_bytes[i] = + u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16).map_err(|_| { + WalletError::Derivation("invalid policy hex in asset key".into()) + })?; + } + let policy = Hash::<28>::new(policy_bytes); + let mut name_bytes = Vec::with_capacity(name_hex.len() / 2); + for i in (0..name_hex.len()).step_by(2) { + name_bytes.push( + u8::from_str_radix(&name_hex[i..i + 2], 16) + .map_err(|_| WalletError::Derivation("invalid name hex".into()))?, + ); + } + change_out = change_out + .add_asset(policy, name_bytes, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; } - let pol_hex = &k[..56]; - let name_hex = &k[56..]; - let mut policy_bytes = [0u8; 28]; - for i in 0..28 { - policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16) - .map_err(|_| WalletError::Derivation("invalid policy hex in asset key".into()))?; + staging = staging.output(change_out); + for cb in &cert_bytes_list { + staging = staging.add_certificate(cb.clone()); } - let policy = Hash::<28>::new(policy_bytes); - let mut name_bytes = Vec::with_capacity(name_hex.len() / 2); - for i in (0..name_hex.len()).step_by(2) { - name_bytes.push( - u8::from_str_radix(&name_hex[i..i + 2], 16) - .map_err(|_| WalletError::Derivation("invalid name hex".into()))?, - ); - } - change_out = change_out - .add_asset(policy, name_bytes, *q) - .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; - } - staging = staging.output(change_out); - for cb in &cert_bytes_list { - staging = staging.add_certificate(cb.clone()); - } - staging = staging.fee(fee).network_id(network_id); - Ok(staging) - }; + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; // Pass 1 — measure unsigned size. let change_pass1 = total_in @@ -217,11 +217,11 @@ pub fn build_signed_stake_delegation( let real_fee = params.min_fee_for_size(est_signed); let token_change = !input_assets.is_empty(); - let final_change = total_in - .checked_sub(deposit + real_fee) - .ok_or_else(|| WalletError::Derivation(format!( + let final_change = total_in.checked_sub(deposit + real_fee).ok_or_else(|| { + WalletError::Derivation(format!( "insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}" - )))?; + )) + })?; if final_change < params.min_utxo_lovelace && token_change { return Err(WalletError::Derivation(format!( "insufficient ADA for token-bearing change: change={final_change}, min={}", diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index e06a3c1..63bfbcc 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -45,7 +45,9 @@ use ed25519_bip32::XPrv; use pallas_addresses::Address as PallasAddress; use pallas_crypto::key::ed25519::SecretKeyExtended; -use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction}; +use pallas_txbuilder::{ + BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction, +}; /// Reference-script attached to a tx output. Used to deploy Plutus /// validators / minting policies as reusable on-chain references so @@ -488,16 +490,13 @@ pub struct UnsignedPayment { pub summary: PaymentSummary, } -fn build_unsigned_bytes( - staging: StagingTransaction, -) -> Result, WalletError> { +fn build_unsigned_bytes(staging: StagingTransaction) -> Result, WalletError> { let built = staging .build_conway_raw() .map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?; Ok(built.tx_bytes.0) } - /// Internal helper — runs the two-pass fee refinement and returns /// the final `BuiltTransaction` plus a `PaymentSummary` describing /// the body. Handles both ADA-only and multi-asset payments; pass @@ -1247,7 +1246,11 @@ mod tests { ) .expect("multi-asset payment builds + signs"); // Multi-asset tx is meaningfully larger than ADA-only. - assert!(cbor.len() > 200, "cbor unexpectedly short: {} bytes", cbor.len()); + assert!( + cbor.len() > 200, + "cbor unexpectedly short: {} bytes", + cbor.len() + ); } #[test] diff --git a/crates/aldabra-dao/examples/repro_script_corruption.rs b/crates/aldabra-dao/examples/repro_script_corruption.rs index f1674c0..2fbc8fe 100644 --- a/crates/aldabra-dao/examples/repro_script_corruption.rs +++ b/crates/aldabra-dao/examples/repro_script_corruption.rs @@ -45,9 +45,7 @@ fn find_subseq(haystack: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || needle.len() > haystack.len() { return None; } - haystack - .windows(needle.len()) - .position(|w| w == needle) + haystack.windows(needle.len()).position(|w| w == needle) } fn main() { @@ -79,10 +77,9 @@ fn main() { // A throwaway preprod testnet enterprise script address (just for // shape — no funds, no real chain interaction). - let dest_addr = Address::from_bech32( - "addr_test1wptadvtl64h74jmhwuda595j40ss3rgh0p9jam0ejwgz6mcnzvusa", - ) - .expect("decode addr"); + let dest_addr = + Address::from_bech32("addr_test1wptadvtl64h74jmhwuda595j40ss3rgh0p9jam0ejwgz6mcnzvusa") + .expect("decode addr"); let mut output = TxOutput::new(dest_addr, 5_000_000); output = output.set_inline_script(ScriptKind::PlutusV2, script_bytes.clone()); @@ -93,9 +90,7 @@ fn main() { .fee(2_000_000) .network_id(0); - let built = staging - .build_conway_raw() - .expect("build_conway_raw failed"); + let built = staging.build_conway_raw().expect("build_conway_raw failed"); let tx_bytes = built.tx_bytes.0; println!("built tx body: {} bytes", tx_bytes.len()); @@ -105,7 +100,10 @@ fn main() { // wrapping the inner array `[2, bytes]`. The actual script bytes // are then nested inside that. Search for them verbatim. if let Some(pos) = find_subseq(&tx_bytes, &script_bytes) { - println!("✅ FOUND input script bytes verbatim at tx-body offset {}", pos); + println!( + "✅ FOUND input script bytes verbatim at tx-body offset {}", + pos + ); println!(" pallas-txbuilder serialized them clean."); // BUT: check the bytes-header that precedes them. In CBOR, a diff --git a/crates/aldabra-dao/src/agora/governor.rs b/crates/aldabra-dao/src/agora/governor.rs index 4d07058..ecba7c4 100644 --- a/crates/aldabra-dao/src/agora/governor.rs +++ b/crates/aldabra-dao/src/agora/governor.rs @@ -149,7 +149,10 @@ mod tests { assert_eq!(gov.proposal_timings.locking_time, 48 * 3600 * 1000); assert_eq!(gov.proposal_timings.executing_time, 24 * 3600 * 1000); assert_eq!(gov.proposal_timings.min_stake_voting_time, 60 * 60 * 1000); - assert_eq!(gov.proposal_timings.voting_time_range_max_width, 30 * 60 * 1000); + assert_eq!( + gov.proposal_timings.voting_time_range_max_width, + 30 * 60 * 1000 + ); assert_eq!(gov.create_proposal_time_range_max_width, 30 * 60 * 1000); assert_eq!(gov.maximum_created_proposals_per_stake, 20); } diff --git a/crates/aldabra-dao/src/agora/mod.rs b/crates/aldabra-dao/src/agora/mod.rs index 59a1f1f..ab2394a 100644 --- a/crates/aldabra-dao/src/agora/mod.rs +++ b/crates/aldabra-dao/src/agora/mod.rs @@ -48,6 +48,4 @@ pub use proposal::{ ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig, ProposalVotes, }; -pub use stake::{ - Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, -}; +pub use stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer}; diff --git a/crates/aldabra-dao/src/agora/plutus_data.rs b/crates/aldabra-dao/src/agora/plutus_data.rs index acb02d2..99ed238 100644 --- a/crates/aldabra-dao/src/agora/plutus_data.rs +++ b/crates/aldabra-dao/src/agora/plutus_data.rs @@ -70,7 +70,9 @@ pub fn as_product(pd: &PlutusData) -> DaoResult<&Vec> { /// none currently do. pub fn int(n: i128) -> DaoResult { let i = i64::try_from(n).map_err(|_| { - DaoError::Datum(format!("integer {n} exceeds i64 — needs BigInt::Big{{U,N}}Int impl")) + DaoError::Datum(format!( + "integer {n} exceeds i64 — needs BigInt::Big{{U,N}}Int impl" + )) })?; Ok(PlutusData::BigInt(BigInt::Int(i.into()))) } @@ -98,13 +100,10 @@ pub fn as_constr(pd: &PlutusData) -> DaoResult<(u64, &Vec)> { c.tag ))); }; - let (MaybeIndefArray::Def(ref fields) | MaybeIndefArray::Indef(ref fields)) = - c.fields; + let (MaybeIndefArray::Def(ref fields) | MaybeIndefArray::Indef(ref fields)) = c.fields; Ok((idx, fields)) } - other => Err(DaoError::Datum(format!( - "expected Constr, got {other:?}" - ))), + other => Err(DaoError::Datum(format!("expected Constr, got {other:?}"))), } } @@ -137,9 +136,7 @@ pub fn as_int(pd: &PlutusData) -> DaoResult { let n = i128::from_be_bytes(buf); Ok(-n - 1) } - other => Err(DaoError::Datum(format!( - "expected BigInt, got {other:?}" - ))), + other => Err(DaoError::Datum(format!("expected BigInt, got {other:?}"))), } } @@ -161,12 +158,9 @@ pub fn as_bytes(pd: &PlutusData) -> DaoResult> { /// Decode a PlutusData::Array (works for both Def and Indef encodings). pub fn as_array(pd: &PlutusData) -> DaoResult<&Vec> { match pd { - PlutusData::Array(MaybeIndefArray::Def(v)) | PlutusData::Array(MaybeIndefArray::Indef(v)) => { - Ok(v) - } - other => Err(DaoError::Datum(format!( - "expected Array, got {other:?}" - ))), + PlutusData::Array(MaybeIndefArray::Def(v)) + | PlutusData::Array(MaybeIndefArray::Indef(v)) => Ok(v), + other => Err(DaoError::Datum(format!("expected Array, got {other:?}"))), } } @@ -174,9 +168,7 @@ pub fn as_array(pd: &PlutusData) -> DaoResult<&Vec> { pub fn as_map(pd: &PlutusData) -> DaoResult> { match pd { PlutusData::Map(kvp) => Ok(kvp.iter().map(|(k, v)| (k, v)).collect()), - other => Err(DaoError::Datum(format!( - "expected Map, got {other:?}" - ))), + other => Err(DaoError::Datum(format!("expected Map, got {other:?}"))), } } diff --git a/crates/aldabra-dao/src/agora/proposal.rs b/crates/aldabra-dao/src/agora/proposal.rs index 3ec3407..66a537a 100644 --- a/crates/aldabra-dao/src/agora/proposal.rs +++ b/crates/aldabra-dao/src/agora/proposal.rs @@ -16,9 +16,7 @@ use pallas_codec::utils::{KeyValuePairs, MaybeIndefArray}; use pallas_primitives::PlutusData; -use crate::agora::plutus_data::{ - as_array, as_int, as_map, as_product, constr, int, product, -}; +use crate::agora::plutus_data::{as_array, as_int, as_map, as_product, constr, int, product}; use crate::agora::stake::Credential; use crate::error::{DaoError, DaoResult}; @@ -193,11 +191,8 @@ pub struct ProposalDatum { impl ProposalDatum { pub fn to_plutus_data(&self) -> DaoResult { - let cosigners_pd: Vec = self - .cosigners - .iter() - .map(|c| c.to_plutus_data()) - .collect(); + let cosigners_pd: Vec = + self.cosigners.iter().map(|c| c.to_plutus_data()).collect(); Ok(product(vec![ int(self.proposal_id as i128)?, self.effects_raw.clone(), diff --git a/crates/aldabra-dao/src/agora/stake.rs b/crates/aldabra-dao/src/agora/stake.rs index 8e1178f..b0765ce 100644 --- a/crates/aldabra-dao/src/agora/stake.rs +++ b/crates/aldabra-dao/src/agora/stake.rs @@ -78,7 +78,10 @@ impl ProposalAction { pub fn to_plutus_data(&self) -> DaoResult { Ok(match self { ProposalAction::Created => constr(0, vec![]), - ProposalAction::Voted { result_tag, posix_time } => constr( + ProposalAction::Voted { + result_tag, + posix_time, + } => constr( 1, vec![int(*result_tag as i128)?, int(*posix_time as i128)?], ), @@ -106,7 +109,10 @@ impl ProposalAction { } let result_tag = as_int(&fields[0])? as i64; let posix_time = as_int(&fields[1])? as i64; - ProposalAction::Voted { result_tag, posix_time } + ProposalAction::Voted { + result_tag, + posix_time, + } } 2 => { if !fields.is_empty() { @@ -154,7 +160,10 @@ impl ProposalLock { } let proposal_id = as_int(&fields[0])? as i64; let action = ProposalAction::from_plutus_data(&fields[1])?; - Ok(ProposalLock { proposal_id, action }) + Ok(ProposalLock { + proposal_id, + action, + }) } } @@ -217,12 +226,10 @@ impl StakeDatum { match (j, f.len()) { (0, 1) => Some(Credential::from_plutus_data(&f[0])?), (1, 0) => None, - _ => { - return Err(DaoError::Datum(format!( - "Maybe expects Constr 0[1] | 1[0], got Constr {j} with {} fields", - f.len() - ))) - } + _ => return Err(DaoError::Datum(format!( + "Maybe expects Constr 0[1] | 1[0], got Constr {j} with {} fields", + f.len() + ))), } }; let locked_by = as_array(&fields[3])? @@ -352,7 +359,8 @@ mod tests { #[test] fn decodes_sulkta_live_kayos_stake() { use pallas_primitives::PlutusData; - let cbor_hex = "9f1832d8799f581c84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3ffd87a8080ff"; + let cbor_hex = + "9f1832d8799f581c84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3ffd87a8080ff"; let bytes = hex::decode(cbor_hex).unwrap(); let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap(); let stake = StakeDatum::from_plutus_data(&pd).expect("decode Kayos stake"); @@ -371,7 +379,7 @@ mod tests { // Plutus-structurally-equal so the validator's `==` accepts // either. The meaningful invariant is: round-trip preserves // every typed field, no silent drift across encode/decode. - let re_encoded = pallas_codec::minicbor::to_vec(&stake.to_plutus_data().unwrap()).unwrap(); + let re_encoded = pallas_codec::minicbor::to_vec(stake.to_plutus_data().unwrap()).unwrap(); let re_pd: pallas_primitives::PlutusData = pallas_codec::minicbor::decode(&re_encoded).unwrap(); let round_tripped = StakeDatum::from_plutus_data(&re_pd).expect("re-decode"); @@ -384,7 +392,8 @@ mod tests { #[test] fn decodes_sulkta_live_cobb_stake() { use pallas_primitives::PlutusData; - let cbor_hex = "9f18fad8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffd87a8080ff"; + let cbor_hex = + "9f18fad8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffd87a8080ff"; let bytes = hex::decode(cbor_hex).unwrap(); let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap(); let stake = StakeDatum::from_plutus_data(&pd).expect("decode Cobb stake"); @@ -396,7 +405,7 @@ mod tests { assert!(stake.delegated_to.is_none()); assert!(stake.locked_by.is_empty()); - let re_encoded = pallas_codec::minicbor::to_vec(&stake.to_plutus_data().unwrap()).unwrap(); + let re_encoded = pallas_codec::minicbor::to_vec(stake.to_plutus_data().unwrap()).unwrap(); let re_pd: pallas_primitives::PlutusData = pallas_codec::minicbor::decode(&re_encoded).unwrap(); let round_tripped = StakeDatum::from_plutus_data(&re_pd).expect("re-decode"); @@ -410,7 +419,10 @@ mod tests { (StakeRedeemer::Destroy, 1), (StakeRedeemer::PermitVote, 2), (StakeRedeemer::RetractVotes, 3), - (StakeRedeemer::DelegateTo(Credential::PubKey(vec![0u8; 28])), 4), + ( + StakeRedeemer::DelegateTo(Credential::PubKey(vec![0u8; 28])), + 4, + ), (StakeRedeemer::ClearDelegate, 5), ]; for (r, expected_idx) in cases { diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index c0e8e2c..ec36732 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -17,9 +17,9 @@ //! | def. | `stake_create` | Lock TRP at stakes script (deferred — both | //! | | | live wallets already have stakes) | -pub mod proposal_create; -pub mod proposal_vote; -pub mod proposal_cosign; pub mod proposal_advance; +pub mod proposal_cosign; +pub mod proposal_create; pub mod proposal_retract_votes; +pub mod proposal_vote; pub mod stake_destroy; diff --git a/crates/aldabra-dao/src/builder/proposal_advance.rs b/crates/aldabra-dao/src/builder/proposal_advance.rs index 2b430ac..076070f 100644 --- a/crates/aldabra-dao/src/builder/proposal_advance.rs +++ b/crates/aldabra-dao/src/builder/proposal_advance.rs @@ -71,19 +71,19 @@ use pallas_crypto::hash::Hash; use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction}; use crate::agora::proposal::{ - ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, - ProposalTimingConfig, ProposalVotes, + ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig, + ProposalVotes, }; use crate::agora::stake::Credential; use crate::config::{DaoConfig, DaoNetwork}; use crate::error::{DaoError, DaoResult}; +use super::proposal_cosign::insert_unique_sorted; use super::proposal_create::{ parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, WalletUtxo, MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as ADVANCE_SPEND_EX_UNITS, SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS, }; -use super::proposal_cosign::insert_unique_sorted; use super::proposal_vote::ProposalUtxoIn; const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; @@ -114,8 +114,9 @@ impl AdvanceTransition { AdvanceTransition::DraftToVotingReady | AdvanceTransition::DraftToFinished => { ProposalStatus::Draft } - AdvanceTransition::VotingReadyToLocked - | AdvanceTransition::VotingReadyToFinished => ProposalStatus::VotingReady, + AdvanceTransition::VotingReadyToLocked | AdvanceTransition::VotingReadyToFinished => { + ProposalStatus::VotingReady + } AdvanceTransition::LockedToFinished => ProposalStatus::Locked, } } @@ -225,10 +226,8 @@ pub fn build_unsigned_proposal_advance( sorted_ref_owners = insert_unique_sorted(&sorted_ref_owners, &r.owner)?; } if sorted_ref_owners != args.proposal.datum.cosigners { - return Err(DaoError::State(format!( - "sorted cosigner-stake owners do not match proposal.cosigners exactly — \ - ref order or membership wrong" - ))); + return Err(DaoError::State("sorted cosigner-stake owners do not match proposal.cosigners exactly — \ + ref order or membership wrong".to_string())); } // (iii) sum of staked_amounts ≥ thresholds.to_voting. let total: i128 = args @@ -306,13 +305,10 @@ pub fn build_unsigned_proposal_advance( let funding = ada_only .iter() .find(|u| { - !(u.tx_hash_hex == collateral.tx_hash_hex - && u.output_index == collateral.output_index) + !(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 for funding".into()) - })?; + .ok_or_else(|| DaoError::State("need a SECOND ada-only wallet UTxO for funding".into()))?; // ---- new proposal datum: only status mutated ------------------------ @@ -363,16 +359,22 @@ pub fn build_unsigned_proposal_advance( // ---- assemble StagingTransaction ------------------------------------ - let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| { - DaoError::Config("proposal_addr not set on DaoConfig".into()) - })?)?; + let proposal_addr = parse_address( + args.cfg + .proposal_addr + .as_deref() + .ok_or_else(|| DaoError::Config("proposal_addr not set on DaoConfig".into()))?, + )?; let change_addr = parse_address(&args.change_address)?; let proposal_input = Input::new( parse_tx_hash(&args.proposal.tx_hash_hex)?, args.proposal.output_index as u64, ); - let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.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, @@ -382,9 +384,12 @@ pub fn build_unsigned_proposal_advance( args.proposal_validator_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".into()) - })?)?; + 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".into()))?, + )?; let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex) .map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?; @@ -454,14 +459,12 @@ pub fn build_unsigned_proposal_advance( staging = staging.valid_from_slot(valid_from); staging = staging.invalid_from_slot(invalid_from); - let advancer_pkh_arr: [u8; 28] = args - .advancer_pkh - .as_slice() - .try_into() - .map_err(|_| DaoError::Datum(format!( + let advancer_pkh_arr: [u8; 28] = args.advancer_pkh.as_slice().try_into().map_err(|_| { + DaoError::Datum(format!( "advancer_pkh must be 28 bytes, got {}", args.advancer_pkh.len() - )))?; + )) + })?; staging = staging.disclosed_signer(Hash::<28>::from(advancer_pkh_arr)); staging = staging.fee(args.fee_lovelace).network_id(network_id); @@ -504,8 +507,12 @@ mod tests { use crate::agora::plutus_data::constr; use crate::config::ScriptRefs; - fn pkh_a() -> Vec { vec![0x10; 28] } - fn pkh_b() -> Vec { vec![0x80; 28] } + fn pkh_a() -> Vec { + vec![0x10; 28] + } + fn pkh_b() -> Vec { + vec![0x80; 28] + } fn advancer_pkh() -> Vec { hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() } @@ -515,10 +522,7 @@ mod tests { proposal_id: 1, effects_raw: constr(0, vec![]), status: ProposalStatus::Draft, - cosigners: vec![ - Credential::PubKey(pkh_a()), - Credential::PubKey(pkh_b()), - ], + cosigners: vec![Credential::PubKey(pkh_a()), Credential::PubKey(pkh_b())], thresholds: ProposalThresholds { execute: 20, create: 100, diff --git a/crates/aldabra-dao/src/builder/proposal_cosign.rs b/crates/aldabra-dao/src/builder/proposal_cosign.rs index a83e031..6ef4ec9 100644 --- a/crates/aldabra-dao/src/builder/proposal_cosign.rs +++ b/crates/aldabra-dao/src/builder/proposal_cosign.rs @@ -56,12 +56,10 @@ use pallas_crypto::hash::Hash; use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction}; use crate::agora::proposal::{ - ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, - ProposalTimingConfig, ProposalVotes, -}; -use crate::agora::stake::{ - Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, + ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig, + ProposalVotes, }; +use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer}; use crate::config::{DaoConfig, DaoNetwork}; use crate::error::{DaoError, DaoResult}; @@ -128,9 +126,7 @@ pub(super) fn insert_unique_sorted( // Check for duplicate. for c in list { if key(c) == new_key { - return Err(DaoError::State(format!( - "credential already in cosigner list — pinsertUniqueBy would reject" - ))); + return Err(DaoError::State("credential already in cosigner list — pinsertUniqueBy would reject".to_string())); } } // Find insertion point. @@ -184,8 +180,7 @@ pub fn build_unsigned_proposal_cosign( // (4) Insert cosigner into sorted-unique list. Errors on duplicate. let cosigner_cred = Credential::PubKey(args.cosigner_pkh.clone()); - let new_cosigners = - insert_unique_sorted(&args.proposal.datum.cosigners, &cosigner_cred)?; + let new_cosigners = insert_unique_sorted(&args.proposal.datum.cosigners, &cosigner_cred)?; // (5) Length check. if (new_cosigners.len() as u32) > args.cfg.max_cosigners { @@ -221,14 +216,11 @@ pub fn build_unsigned_proposal_cosign( let funding = ada_only .iter() .find(|u| { - !(u.tx_hash_hex == collateral.tx_hash_hex - && u.output_index == collateral.output_index) + !(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".into(), - ) + DaoError::State("need a SECOND ada-only wallet UTxO to fund the spend".into()) })?; // ---- compute new datums --------------------------------------------- @@ -253,7 +245,9 @@ pub fn build_unsigned_proposal_cosign( effects_raw: args.proposal.datum.effects_raw.clone(), status: args.proposal.datum.status, cosigners: new_cosigners.clone(), - thresholds: ProposalThresholds { ..args.proposal.datum.thresholds.clone() }, + thresholds: ProposalThresholds { + ..args.proposal.datum.thresholds.clone() + }, votes: ProposalVotes(args.proposal.datum.votes.0.clone()), timing_config: ProposalTimingConfig { ..args.proposal.datum.timing_config.clone() @@ -270,9 +264,8 @@ pub fn build_unsigned_proposal_cosign( // ---- redeemers ------------------------------------------------------- - let stake_spend_redeemer_cbor = - minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?) - .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; + let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; let proposal_spend_redeemer_cbor = minicbor::to_vec(&ProposalRedeemer::Cosign.to_plutus_data()?) .map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?; @@ -305,9 +298,12 @@ pub fn build_unsigned_proposal_cosign( // ---- assemble StagingTransaction ------------------------------------- let stakes_addr = parse_address(&args.cfg.stakes_addr)?; - let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| { - DaoError::Config("proposal_addr not set on DaoConfig".into()) - })?)?; + let proposal_addr = parse_address( + args.cfg + .proposal_addr + .as_deref() + .ok_or_else(|| DaoError::Config("proposal_addr not set on DaoConfig".into()))?, + )?; let change_addr = parse_address(&args.change_address)?; let stake_input = Input::new( @@ -318,7 +314,10 @@ pub fn build_unsigned_proposal_cosign( parse_tx_hash(&args.proposal.tx_hash_hex)?, args.proposal.output_index as u64, ); - let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.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, @@ -332,14 +331,20 @@ pub fn build_unsigned_proposal_cosign( args.proposal_validator_ref.output_index as u64, ); - 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".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".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 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".into()) - })?)?; + 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".into()))?, + )?; let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex) .map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?; let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?; @@ -354,11 +359,13 @@ pub fn build_unsigned_proposal_cosign( 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, 1) - .and_then(|o| o.add_asset( - gov_token_policy_hash, - gov_token_name_bytes, - args.stake_in.gov_token_qty, - )) + .and_then(|o| { + o.add_asset( + gov_token_policy_hash, + gov_token_name_bytes, + args.stake_in.gov_token_qty, + ) + }) .map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?; let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace) @@ -403,14 +410,12 @@ pub fn build_unsigned_proposal_cosign( staging = staging.valid_from_slot(args.tip_slot); staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS); - let cosigner_pkh_arr: [u8; 28] = args - .cosigner_pkh - .as_slice() - .try_into() - .map_err(|_| DaoError::Datum(format!( + let cosigner_pkh_arr: [u8; 28] = args.cosigner_pkh.as_slice().try_into().map_err(|_| { + DaoError::Datum(format!( "cosigner_pkh must be 28 bytes, got {}", args.cosigner_pkh.len() - )))?; + )) + })?; staging = staging.disclosed_signer(Hash::<28>::from(cosigner_pkh_arr)); staging = staging.fee(args.fee_lovelace).network_id(network_id); @@ -458,17 +463,19 @@ mod tests { hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() } - fn other_pkh_a() -> Vec { vec![0x10u8; 28] } - fn other_pkh_b() -> Vec { vec![0xf0u8; 28] } + fn other_pkh_a() -> Vec { + vec![0x10u8; 28] + } + fn other_pkh_b() -> Vec { + vec![0xf0u8; 28] + } fn sample_proposal_datum() -> ProposalDatum { ProposalDatum { proposal_id: 1, effects_raw: constr(0, vec![]), status: ProposalStatus::Draft, - cosigners: vec![ - Credential::PubKey(other_pkh_a()), - ], + cosigners: vec![Credential::PubKey(other_pkh_a())], thresholds: ProposalThresholds { execute: 20, create: 100, @@ -598,7 +605,10 @@ mod tests { fn rejects_duplicate_cosigner() { let mut args = sample_args(); // Add cosigner_pkh as already-present cosigner. - args.proposal.datum.cosigners.push(Credential::PubKey(cosigner_pkh())); + args.proposal + .datum + .cosigners + .push(Credential::PubKey(cosigner_pkh())); let err = build_unsigned_proposal_cosign(args).unwrap_err(); assert!(err.to_string().contains("already in cosigner list")); } diff --git a/crates/aldabra-dao/src/builder/proposal_create.rs b/crates/aldabra-dao/src/builder/proposal_create.rs index ca583c6..4cfa2ae 100644 --- a/crates/aldabra-dao/src/builder/proposal_create.rs +++ b/crates/aldabra-dao/src/builder/proposal_create.rs @@ -49,9 +49,7 @@ 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::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer}; use crate::config::{DaoConfig, DaoNetwork}; use crate::error::{DaoError, DaoResult}; @@ -156,9 +154,9 @@ impl ReferenceUtxo { 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}")) - })?; + 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, @@ -247,9 +245,7 @@ pub fn build_unsigned_proposal_create( // 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" - ))); + return Err(DaoError::State("stake owner pkh does not match proposer pkh — proposer must own the stake input".to_string())); } let create_threshold = args.governor.datum.proposal_thresholds.create; if (args.stake_in.datum.staked_amount as i128) < (create_threshold as i128) { @@ -303,8 +299,7 @@ pub fn build_unsigned_proposal_create( let funding = ada_only .iter() .find(|u| { - !(u.tx_hash_hex == collateral.tx_hash_hex - && u.output_index == collateral.output_index) + !(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index) }) .cloned() .ok_or_else(|| { @@ -332,9 +327,8 @@ pub fn build_unsigned_proposal_create( // // 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 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), @@ -345,10 +339,14 @@ pub fn build_unsigned_proposal_create( effects_raw: effects_pd, status: ProposalStatus::Draft, cosigners: vec![proposer_cred.clone()], - thresholds: ProposalThresholds { ..args.governor.datum.proposal_thresholds.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() }, + timing_config: ProposalTimingConfig { + ..args.governor.datum.proposal_timings.clone() + }, starting_time: args.starting_time_ms, }; @@ -394,15 +392,12 @@ pub fn build_unsigned_proposal_create( // 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::PermitVote.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}")))?; + 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::PermitVote.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 ------------------------------------------------- // @@ -463,7 +458,10 @@ pub fn build_unsigned_proposal_create( 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 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, @@ -481,16 +479,19 @@ pub fn build_unsigned_proposal_create( 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 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)?; @@ -516,11 +517,13 @@ pub fn build_unsigned_proposal_create( 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, - )) + .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. @@ -596,7 +599,8 @@ pub fn build_unsigned_proposal_create( // Sulkta-shape governors with 30min windows, the legacy 1799-slot // const fits. For tiny test DAOs (preprod_test: 30s) it must shrink // to the per-DAO budget. Subtract 1 slot for safety against round-up. - let max_width_slots = ((args.governor.datum.create_proposal_time_range_max_width / 1_000) as u64) + let max_width_slots = ((args.governor.datum.create_proposal_time_range_max_width / 1_000) + as u64) .saturating_sub(1) .min(VALIDITY_RANGE_SLOTS); // 2026-05-07: anchor the validity range to caller-supplied @@ -625,14 +629,12 @@ pub fn build_unsigned_proposal_create( staging = staging.valid_from_slot(valid_from); staging = staging.invalid_from_slot(invalid_from); - let proposer_pkh_arr: [u8; 28] = args - .proposer_pkh - .as_slice() - .try_into() - .map_err(|_| DaoError::Datum(format!( + 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); @@ -696,7 +698,8 @@ pub(super) fn parse_tx_hash(hex_str: &str) -> DaoResult> { } pub(super) fn parse_script_hash(hex_str: &str) -> DaoResult> { - let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("script_hash hex: {e}")))?; + 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 {}", diff --git a/crates/aldabra-dao/src/builder/proposal_retract_votes.rs b/crates/aldabra-dao/src/builder/proposal_retract_votes.rs index 2bb2ebb..f4f055c 100644 --- a/crates/aldabra-dao/src/builder/proposal_retract_votes.rs +++ b/crates/aldabra-dao/src/builder/proposal_retract_votes.rs @@ -86,12 +86,8 @@ use pallas_codec::minicbor; use pallas_crypto::hash::Hash; use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction}; -use crate::agora::proposal::{ - ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes, -}; -use crate::agora::stake::{ - Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, -}; +use crate::agora::proposal::{ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes}; +use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer}; use crate::config::{DaoConfig, DaoNetwork}; use crate::error::{DaoError, DaoResult}; @@ -176,7 +172,8 @@ pub fn build_unsigned_proposal_retract_votes( }; if !voter_is_owner && !voter_is_delegate { return Err(DaoError::State( - "voter pkh is neither stake owner nor delegatee — cannot retract with this stake".into(), + "voter pkh is neither stake owner nor delegatee — cannot retract with this stake" + .into(), )); } @@ -210,21 +207,28 @@ pub fn build_unsigned_proposal_retract_votes( args.proposal.datum.starting_time + args.proposal.datum.timing_config.draft_time; let voting_end_ms = voting_start_ms + args.proposal.datum.timing_config.voting_time; let tx_lower_ms = args.validity_lower_ms; - let tx_upper_ms = tx_lower_ms - + (VALIDITY_RANGE_SLOTS as i64) * 1000; + let tx_upper_ms = tx_lower_ms + (VALIDITY_RANGE_SLOTS as i64) * 1000; let in_voting_window = tx_lower_ms >= voting_start_ms && tx_upper_ms <= voting_end_ms; - let proposal_datum_will_change = args.proposal.datum.status == ProposalStatus::VotingReady - && in_voting_window; + let proposal_datum_will_change = + args.proposal.datum.status == ProposalStatus::VotingReady && in_voting_window; - // Voter cooldown preflight (only applies when removing Voted locks - // outside the RemoveAllLocks path — i.e. proposal is NOT Finished). - // Per `premoveLocks`, a Voted lock must satisfy - // `createdAt + minStakeVotingTime ≤ lowerBound` to be removable. + // Voter cooldown preflight. Per Agora's `premoveLocks`, a Voted lock + // must satisfy `createdAt + minStakeVotingTime ≤ lowerBound` to be + // removable — UNLESS the retract also mutates the proposal's vote + // tally (i.e. retracting during the voting window of a VotingReady + // proposal). In that path the validator takes a different branch + // (Vote-with-RetractVotes / UnlockStake) where cooldown does NOT + // apply. Cooldown only matters for "lock cleanup after voting + // closed but before Finished" — the post-window pre-Finished case. let unlock_cooldown = args.proposal.datum.timing_config.min_stake_voting_time; let mut voted_lock_to_retract: Option<&ProposalLock> = None; for lock in &locks_for_proposal { if let ProposalAction::Voted { posix_time, .. } = &lock.action { - if matches!(mode, RetractMode::RemoveVoterLockOnly) { + // Skip cooldown when proposal datum WILL change (in-voting-window + // path) or when we're in RemoveAllLocks mode (Finished path). + let cooldown_required = + matches!(mode, RetractMode::RemoveVoterLockOnly) && !proposal_datum_will_change; + if cooldown_required { let ready_at = posix_time .checked_add(unlock_cooldown) .ok_or_else(|| DaoError::State("cooldown overflow".into()))?; @@ -232,11 +236,7 @@ pub fn build_unsigned_proposal_retract_votes( return Err(DaoError::State(format!( "Voted lock for proposal #{} not past cooldown yet: \ tx_lower_ms={} < createdAt({})+minStakeVotingTime({})={}", - proposal_id, - tx_lower_ms, - posix_time, - unlock_cooldown, - ready_at + proposal_id, tx_lower_ms, posix_time, unlock_cooldown, ready_at ))); } } @@ -386,8 +386,7 @@ pub fn build_unsigned_proposal_retract_votes( let funding = ada_only .iter() .find(|u| { - !(u.tx_hash_hex == collateral.tx_hash_hex - && u.output_index == collateral.output_index) + !(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index) }) .cloned() .ok_or_else(|| { @@ -440,7 +439,10 @@ pub fn build_unsigned_proposal_retract_votes( parse_tx_hash(&args.proposal.tx_hash_hex)?, args.proposal.output_index as u64, ); - let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.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, @@ -454,14 +456,20 @@ pub fn build_unsigned_proposal_retract_votes( args.proposal_validator_ref.output_index as u64, ); - 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".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".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 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".into()) - })?)?; + 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".into()))?, + )?; let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex) .map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?; let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?; @@ -476,11 +484,13 @@ pub fn build_unsigned_proposal_retract_votes( 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, 1) - .and_then(|o| o.add_asset( - gov_token_policy_hash, - gov_token_name_bytes, - args.stake_in.gov_token_qty, - )) + .and_then(|o| { + o.add_asset( + gov_token_policy_hash, + gov_token_name_bytes, + args.stake_in.gov_token_qty, + ) + }) .map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?; let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace) @@ -532,14 +542,12 @@ pub fn build_unsigned_proposal_retract_votes( staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS); // Disclosed signer: voter pkh. - let voter_pkh_arr: [u8; 28] = args - .voter_pkh - .as_slice() - .try_into() - .map_err(|_| DaoError::Datum(format!( + let voter_pkh_arr: [u8; 28] = args.voter_pkh.as_slice().try_into().map_err(|_| { + DaoError::Datum(format!( "voter_pkh must be 28 bytes, got {}", args.voter_pkh.len() - )))?; + )) + })?; staging = staging.disclosed_signer(Hash::<28>::from(voter_pkh_arr)); staging = staging.fee(args.fee_lovelace).network_id(network_id); @@ -746,11 +754,7 @@ mod tests { action: ProposalAction::Created, }, ]; - let args = sample_args( - sample_proposal_datum_finished(), - locks, - 1_780_010_000_000, - ); + let args = sample_args(sample_proposal_datum_finished(), locks, 1_780_010_000_000); let unsigned = build_unsigned_proposal_retract_votes(args).expect("build"); assert_eq!(unsigned.proposal_id, 7); assert_eq!(unsigned.locks_removed, 2); @@ -796,11 +800,7 @@ mod tests { proposal_id: 99, action: ProposalAction::Created, }]; - let args = sample_args( - sample_proposal_datum_finished(), - locks, - 1_780_010_000_000, - ); + let args = sample_args(sample_proposal_datum_finished(), locks, 1_780_010_000_000); let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); assert!(err.to_string().contains("no locks for proposal")); } @@ -842,7 +842,8 @@ mod tests { let args = sample_args(proposal, locks, validity_lower); let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); assert!( - err.to_string().contains("validator requires votes to change"), + err.to_string() + .contains("validator requires votes to change"), "unexpected err: {err}" ); } @@ -853,14 +854,12 @@ mod tests { proposal_id: 7, action: ProposalAction::Created, }]; - let mut args = sample_args( - sample_proposal_datum_finished(), - locks, - 1_780_010_000_000, - ); + let mut args = sample_args(sample_proposal_datum_finished(), locks, 1_780_010_000_000); args.voter_pkh = vec![0xee; 28]; let err = build_unsigned_proposal_retract_votes(args).unwrap_err(); - assert!(err.to_string().contains("neither stake owner nor delegatee")); + assert!(err + .to_string() + .contains("neither stake owner nor delegatee")); } #[test] diff --git a/crates/aldabra-dao/src/builder/proposal_vote.rs b/crates/aldabra-dao/src/builder/proposal_vote.rs index 497ab2a..02ec1a9 100644 --- a/crates/aldabra-dao/src/builder/proposal_vote.rs +++ b/crates/aldabra-dao/src/builder/proposal_vote.rs @@ -63,19 +63,15 @@ use pallas_codec::minicbor; use pallas_crypto::hash::Hash; use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction}; -use crate::agora::proposal::{ - ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes, -}; -use crate::agora::stake::{ - Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, -}; +use crate::agora::proposal::{ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes}; +use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer}; use crate::config::{DaoConfig, DaoNetwork}; use crate::error::{DaoError, DaoResult}; use super::proposal_create::{ parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo, MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as VOTE_SPEND_EX_UNITS, - SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS, + SCRIPT_OUTPUT_MIN_LOVELACE, }; /// Wallet-change min-UTxO floor. Same value used in proposal_create. @@ -154,9 +150,7 @@ pub struct UnsignedProposalVote { } /// Build the unsigned proposal-vote tx. -pub fn build_unsigned_proposal_vote( - args: ProposalVoteArgs, -) -> DaoResult { +pub fn build_unsigned_proposal_vote(args: ProposalVoteArgs) -> DaoResult { let proposal_id = args.proposal.datum.proposal_id; // ---- preflight checks ------------------------------------------------ @@ -190,14 +184,9 @@ pub fn build_unsigned_proposal_vote( // (2) Stake must not have already voted on this proposal. Per // `pisVoter # pgetStakeRoles`, a stake "is a voter" if any // ProposalLock for proposal_id has a Voted action. - let already_voted = args - .stake_in - .datum - .locked_by - .iter() - .any(|l| { - l.proposal_id == proposal_id - && matches!(l.action, ProposalAction::Voted { .. }) + let already_voted = + args.stake_in.datum.locked_by.iter().any(|l| { + l.proposal_id == proposal_id && matches!(l.action, ProposalAction::Voted { .. }) }); if already_voted { return Err(DaoError::State(format!( @@ -230,7 +219,13 @@ pub fn build_unsigned_proposal_vote( "result_tag {} is not a valid vote option for proposal #{} — keys are {:?}", args.result_tag, proposal_id, - args.proposal.datum.votes.0.iter().map(|(k, _)| *k).collect::>(), + args.proposal + .datum + .votes + .0 + .iter() + .map(|(k, _)| *k) + .collect::>(), )) })?; @@ -241,8 +236,8 @@ pub fn build_unsigned_proposal_vote( // We set tx upper bound to `validity_upper_ms`; lower bound is implicit // from tip_slot but we ALSO cross-check window membership client-side // since a misconfigured caller (vote_time outside window) wastes ~5 ADA. - let voting_start_ms = args.proposal.datum.starting_time - + args.proposal.datum.timing_config.draft_time; + let voting_start_ms = + args.proposal.datum.starting_time + args.proposal.datum.timing_config.draft_time; let voting_end_ms = voting_start_ms + args.proposal.datum.timing_config.voting_time; if args.validity_upper_ms < voting_start_ms || args.validity_upper_ms > voting_end_ms { return Err(DaoError::State(format!( @@ -284,8 +279,7 @@ pub fn build_unsigned_proposal_vote( let funding = ada_only .iter() .find(|u| { - !(u.tx_hash_hex == collateral.tx_hash_hex - && u.output_index == collateral.output_index) + !(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index) }) .cloned() .ok_or_else(|| { @@ -341,9 +335,8 @@ pub fn build_unsigned_proposal_vote( // ---- redeemers ------------------------------------------------------- - let stake_spend_redeemer_cbor = - minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?) - .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; + let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; let proposal_spend_redeemer_cbor = minicbor::to_vec(&ProposalRedeemer::Vote(args.result_tag).to_plutus_data()?) .map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?; @@ -399,7 +392,10 @@ pub fn build_unsigned_proposal_vote( parse_tx_hash(&args.proposal.tx_hash_hex)?, args.proposal.output_index as u64, ); - let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.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, @@ -413,14 +409,20 @@ pub fn build_unsigned_proposal_vote( args.proposal_validator_ref.output_index as u64, ); - 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".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".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 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".into()) - })?)?; + 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".into()))?, + )?; let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex) .map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?; let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?; @@ -437,11 +439,13 @@ pub fn build_unsigned_proposal_vote( 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, 1) - .and_then(|o| o.add_asset( - gov_token_policy_hash, - gov_token_name_bytes, - args.stake_in.gov_token_qty, - )) + .and_then(|o| { + o.add_asset( + gov_token_policy_hash, + gov_token_name_bytes, + args.stake_in.gov_token_qty, + ) + }) .map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?; // New proposal output: same address, same ProposalST, updated datum. @@ -499,14 +503,12 @@ pub fn build_unsigned_proposal_vote( // Disclosed signer: voter pkh. The validator's `pisSignedBy` checks // this against `txInfoSignatories`. - let voter_pkh_arr: [u8; 28] = args - .voter_pkh - .as_slice() - .try_into() - .map_err(|_| DaoError::Datum(format!( + let voter_pkh_arr: [u8; 28] = args.voter_pkh.as_slice().try_into().map_err(|_| { + DaoError::Datum(format!( "voter_pkh must be 28 bytes, got {}", args.voter_pkh.len() - )))?; + )) + })?; staging = staging.disclosed_signer(Hash::<28>::from(voter_pkh_arr)); staging = staging.fee(args.fee_lovelace).network_id(network_id); @@ -739,7 +741,9 @@ mod tests { let mut args = sample_args(); args.voter_pkh = vec![0xee; 28]; let err = build_unsigned_proposal_vote(args).unwrap_err(); - assert!(err.to_string().contains("neither stake owner nor delegatee")); + assert!(err + .to_string() + .contains("neither stake owner nor delegatee")); } #[test] diff --git a/crates/aldabra-dao/src/builder/stake_destroy.rs b/crates/aldabra-dao/src/builder/stake_destroy.rs index ef78952..d492f14 100644 --- a/crates/aldabra-dao/src/builder/stake_destroy.rs +++ b/crates/aldabra-dao/src/builder/stake_destroy.rs @@ -71,9 +71,7 @@ pub struct UnsignedStakeDestroy { pub summary: String, } -pub fn build_unsigned_stake_destroy( - args: StakeDestroyArgs, -) -> DaoResult { +pub fn build_unsigned_stake_destroy(args: StakeDestroyArgs) -> DaoResult { // ---- preflight ------------------------------------------------------ if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.owner_pkh) { @@ -126,7 +124,7 @@ pub fn build_unsigned_stake_destroy( let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::Destroy.to_plutus_data()?) .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; - let mint_redeemer_cbor = minicbor::to_vec(&crate::agora::plutus_data::constr(0, vec![])) + 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 -------------------------------------------------------- @@ -171,9 +169,12 @@ pub fn build_unsigned_stake_destroy( args.stake_st_policy_ref.output_index as u64, ); - 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".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".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)?; @@ -211,7 +212,10 @@ pub fn build_unsigned_stake_destroy( let mut staging = StagingTransaction::new(); staging = staging.input(stake_input.clone()); if let Some(f) = funding { - staging = staging.input(Input::new(parse_tx_hash(&f.tx_hash_hex)?, f.output_index as u64)); + staging = staging.input(Input::new( + parse_tx_hash(&f.tx_hash_hex)?, + f.output_index as u64, + )); } staging = staging.collateral_input(collateral_input); staging = staging.reference_input(stake_validator_ref_input); @@ -234,14 +238,12 @@ pub fn build_unsigned_stake_destroy( Some(DESTROY_MINT_EX_UNITS), ); - let owner_pkh_arr: [u8; 28] = args - .owner_pkh - .as_slice() - .try_into() - .map_err(|_| DaoError::Datum(format!( + let owner_pkh_arr: [u8; 28] = args.owner_pkh.as_slice().try_into().map_err(|_| { + DaoError::Datum(format!( "owner_pkh must be 28 bytes, got {}", args.owner_pkh.len() - )))?; + )) + })?; staging = staging.disclosed_signer(Hash::<28>::from(owner_pkh_arr)); staging = staging.fee(args.fee_lovelace).network_id(network_id); diff --git a/crates/aldabra-dao/src/config.rs b/crates/aldabra-dao/src/config.rs index cd3c078..fad00da 100644 --- a/crates/aldabra-dao/src/config.rs +++ b/crates/aldabra-dao/src/config.rs @@ -46,17 +46,14 @@ use crate::error::{DaoError, DaoResult}; /// breakage if the core crate's Network enum gains variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum DaoNetwork { + #[default] Mainnet, Preprod, Preview, } -impl Default for DaoNetwork { - fn default() -> Self { - Self::Mainnet - } -} /// One named DAO. Captures every Sulkta-specific value as an /// instance field so the rest of the crate is config-driven. @@ -113,7 +110,6 @@ pub struct DaoConfig { // All optional: existing configs registered before Phase 4 still load. // The dao_discover_scripts MCP tool fills these in by inspecting on-chain // state at the governor / stakes / treasury addresses. - /// Proposal validator address (bech32). Where new proposal UTxOs land. /// Different from stakes_addr / governor_addr — separate parameterized /// validator. Discoverable from any tx that created a proposal. @@ -185,9 +181,7 @@ impl DaoConfig { self.gov_token_name_hex ))); } - if self.treasury_ref_config.len() != 56 - || hex::decode(&self.treasury_ref_config).is_err() - { + if self.treasury_ref_config.len() != 56 || hex::decode(&self.treasury_ref_config).is_err() { return Err(DaoError::Config(format!( "treasury_ref_config {:?} is not 56 hex chars", self.treasury_ref_config @@ -273,7 +267,10 @@ impl DaoStore { pub fn load(&self, name: &str) -> DaoResult { let path = self.config_path(name); let bytes = fs::read(&path).map_err(|_| { - DaoError::Config(format!("DAO {name:?} not registered (no {})", path.display())) + DaoError::Config(format!( + "DAO {name:?} not registered (no {})", + path.display() + )) })?; let cfg: DaoConfig = serde_json::from_slice(&bytes)?; cfg.validate()?; @@ -329,8 +326,8 @@ impl DaoStore { /// Read the active DAO marker. Errors if no DAO is active. pub fn get_active(&self) -> DaoResult { let path = self.active_path(); - let bytes = fs::read(&path) - .map_err(|_| DaoError::Config("no active DAO selected".into()))?; + let bytes = + fs::read(&path).map_err(|_| DaoError::Config("no active DAO selected".into()))?; let name = String::from_utf8(bytes) .map_err(|e| DaoError::Config(format!(".active is not valid UTF-8: {e}")))?; let name = name.trim().to_string(); @@ -367,12 +364,10 @@ mod tests { treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(), gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(), gov_token_name_hex: "546572726170696e".into(), - initial_spend: - "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0" - .into(), + initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0" + .into(), max_cosigners: 5, - treasury_ref_config: - "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(), + treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(), network: DaoNetwork::Mainnet, proposal_addr: None, stake_st_policy: None, diff --git a/crates/aldabra-dao/src/discovery.rs b/crates/aldabra-dao/src/discovery.rs index aa8e4dd..b326fb9 100644 --- a/crates/aldabra-dao/src/discovery.rs +++ b/crates/aldabra-dao/src/discovery.rs @@ -70,8 +70,7 @@ impl KoiosDiscoveryClient { /// ` default header for paid-tier Koios access. Bearer comes /// from `ALDABRA_KOIOS_BEARER` env var only — never from disk. pub fn with_bearer(base_url: impl Into, bearer: Option<&str>) -> Self { - let mut builder = - reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); if let Some(token) = bearer { let mut hdrs = reqwest::header::HeaderMap::new(); let value = format!("Bearer {token}"); @@ -204,25 +203,28 @@ pub async fn discover_scripts( // Match on that explicitly. match client.address_info(&cfg.stakes_addr).await { Ok(infos) => { - let utxos = infos.into_iter().next().map(|i| i.utxo_set).unwrap_or_default(); + let utxos = infos + .into_iter() + .next() + .map(|i| i.utxo_set) + .unwrap_or_default(); let mut found_stake_st = None; for u in &utxos { let assets = match &u.asset_list { Some(a) => a, None => continue, }; - let has_gov = assets - .iter() - .any(|a| a.policy_id == cfg.gov_token_policy); + let has_gov = assets.iter().any(|a| a.policy_id == cfg.gov_token_policy); if !has_gov { continue; } // Match on asset_name == stakes_validator_hash (StakeST tokens // for THIS DAO's stakes will carry the stake validator hash // as their asset name; junk tokens won't). - if let Some(stake_st) = assets.iter().find(|a| { - a.policy_id != cfg.gov_token_policy && a.asset_name == stakes_hash - }) { + if let Some(stake_st) = assets + .iter() + .find(|a| a.policy_id != cfg.gov_token_policy && a.asset_name == stakes_hash) + { found_stake_st = Some(stake_st.policy_id.clone()); break; } @@ -238,9 +240,9 @@ pub async fn discover_scripts( ); } } - Err(e) => report - .gaps - .push(format!("stake_st_policy: address_info failed for stakes_addr: {e}")), + Err(e) => report.gaps.push(format!( + "stake_st_policy: address_info failed for stakes_addr: {e}" + )), } // 3. Reference-script UTxOs at the deployers. @@ -257,13 +259,17 @@ pub async fn discover_scripts( let infos = match client.address_info(deployer).await { Ok(v) => v, Err(e) => { - report.gaps.push(format!( - "deployer {deployer} probe failed: {e}" - )); + report + .gaps + .push(format!("deployer {deployer} probe failed: {e}")); continue; } }; - let utxos = infos.into_iter().next().map(|i| i.utxo_set).unwrap_or_default(); + let utxos = infos + .into_iter() + .next() + .map(|i| i.utxo_set) + .unwrap_or_default(); for u in &utxos { let rs = match &u.reference_script { @@ -300,8 +306,7 @@ pub async fn discover_scripts( } if report.stake_st_policy.is_some() && report.stake_st_policy_ref.is_none() { report.gaps.push( - "stake_st_policy_ref: policy id discovered but no ref-utxo found at deployers" - .into(), + "stake_st_policy_ref: policy id discovered but no ref-utxo found at deployers".into(), ); } @@ -312,9 +317,9 @@ pub async fn discover_scripts( .push("proposal_addr: not auto-discovered in v1; provide via dao_register".into()); } if cfg.proposal_st_policy.is_none() { - report.gaps.push( - "proposal_st_policy: not auto-discovered in v1; provide via dao_register".into(), - ); + report + .gaps + .push("proposal_st_policy: not auto-discovered in v1; provide via dao_register".into()); } Ok(report) @@ -353,22 +358,30 @@ mod tests { fn extracts_script_hash_from_governor_addr() { let h = script_hash_from_addr("addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy") .unwrap(); - assert_eq!(h, "d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7"); + assert_eq!( + h, + "d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7" + ); } #[test] fn extracts_script_hash_from_real_stakes_addr() { - let h = - script_hash_from_addr("addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8") - .unwrap(); - assert_eq!(h, "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"); + let h = script_hash_from_addr("addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8") + .unwrap(); + assert_eq!( + h, + "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4" + ); } #[test] fn extracts_script_hash_from_treasury_addr() { let h = script_hash_from_addr("addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y") .unwrap(); - assert_eq!(h, "9461852b2a4be42055e5083b6595e46af48930183bbe969609fea668"); + assert_eq!( + h, + "9461852b2a4be42055e5083b6595e46af48930183bbe969609fea668" + ); } /// Stub client returning canned address_info for testing the discovery @@ -380,11 +393,7 @@ mod tests { #[async_trait::async_trait] impl DiscoveryClient for StubClient { async fn address_info(&self, address: &str) -> DaoResult> { - Ok(self - .responses - .get(address) - .cloned() - .unwrap_or_default()) + Ok(self.responses.get(address).cloned().unwrap_or_default()) } } @@ -430,9 +439,11 @@ mod tests { quantity: "50".into(), }, UtxoAsset { - policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), + policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696" + .into(), // asset_name MUST match the stakes_addr's script hash for H-6 to pass: - asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(), + asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4" + .into(), quantity: "1".into(), }, ]), @@ -466,10 +477,13 @@ mod tests { let stake_hash = "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"; let mut responses = std::collections::HashMap::new(); - responses.insert(cfg.stakes_addr.clone(), vec![AddressInfo { - address: cfg.stakes_addr.clone(), - utxo_set: vec![], - }]); + responses.insert( + cfg.stakes_addr.clone(), + vec![AddressInfo { + address: cfg.stakes_addr.clone(), + utxo_set: vec![], + }], + ); responses.insert( MAINNET_AGORA_SHARED_DEPLOYER.into(), vec![AddressInfo { @@ -580,14 +594,18 @@ mod tests { }, // Junk NFT — wrong asset_name. Must NOT be picked. UtxoAsset { - policy_id: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff".into(), - asset_name: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(), + policy_id: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + .into(), + asset_name: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .into(), quantity: "1".into(), }, // Real StakeST — asset_name matches stake validator hash. UtxoAsset { - policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), - asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(), + policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696" + .into(), + asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4" + .into(), quantity: "1".into(), }, ]), diff --git a/crates/aldabra-dao/src/reader.rs b/crates/aldabra-dao/src/reader.rs index 1051ae4..353ae38 100644 --- a/crates/aldabra-dao/src/reader.rs +++ b/crates/aldabra-dao/src/reader.rs @@ -100,8 +100,7 @@ impl KoiosDaoReader { /// ` default header for paid-tier Koios access. Bearer is /// supplied by the caller from `ALDABRA_KOIOS_BEARER` env var only. pub fn with_bearer(base_url: impl Into, bearer: Option<&str>) -> Self { - let mut builder = - reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); if let Some(token) = bearer { let mut hdrs = reqwest::header::HeaderMap::new(); let value = format!("Bearer {token}"); @@ -232,7 +231,9 @@ impl DaoReader for KoiosDaoReader { let mut out = Vec::new(); for u in utxos { // Need an inline datum to be a real proposal UTxO. Skip orphans. - let Some(ref d) = u.inline_datum else { continue }; + let Some(ref d) = u.inline_datum else { + continue; + }; let pd = match decode_datum_cbor_hex(&d.bytes) { Ok(pd) => pd, Err(_) => continue, @@ -327,8 +328,7 @@ struct KoiosInlineDatum { /// Uses the same path as `aldabra-core::cip68` round-trip tests: /// `pallas_codec::minicbor::decode(&bytes)`. fn decode_datum_cbor_hex(hex_str: &str) -> DaoResult { - let bytes = - hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("hex decode: {e}")))?; + let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("hex decode: {e}")))?; pallas_codec::minicbor::decode::(&bytes) .map_err(|e| DaoError::Cbor(format!("plutus data decode: {e}"))) } diff --git a/crates/aldabra-mcp/src/bootstrap.rs b/crates/aldabra-mcp/src/bootstrap.rs index ada2d95..c944d93 100644 --- a/crates/aldabra-mcp/src/bootstrap.rs +++ b/crates/aldabra-mcp/src/bootstrap.rs @@ -200,8 +200,7 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result { eprintln!("aldabra: no key found at {}", data_dir.display()); eprintln!("first-run bootstrap — this writes an encrypted mnemonic to disk.\n"); - fs::create_dir_all(data_dir) - .with_context(|| format!("creating {}", data_dir.display()))?; + fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?; eprint!("paste 24-word BIP-39 mnemonic (visible) and press Enter: "); std::io::stderr().flush().ok(); @@ -244,8 +243,7 @@ pub fn import_root_xprv(data_dir: &Path) -> Result { xprv_path.display() )); } - fs::create_dir_all(data_dir) - .with_context(|| format!("creating {}", data_dir.display()))?; + fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?; eprint!("paste root_xsk1... bech32 root extended secret key and press Enter: "); std::io::stderr().flush().ok(); @@ -300,8 +298,7 @@ pub fn generate_and_save_root_key(data_dir: &Path) -> Result { path.display() )); } - fs::create_dir_all(data_dir) - .with_context(|| format!("creating {}", data_dir.display()))?; + fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?; let (mnemonic, phrase) = Mnemonic::generate()?; eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================"); @@ -397,21 +394,13 @@ mod tests { .unwrap() .into_root_key() .unwrap(); - let addr_a = aldabra_core::derive_base_address( - &root_a, - aldabra_core::Network::Mainnet, - 0, - 0, - ) - .unwrap(); + let addr_a = + aldabra_core::derive_base_address(&root_a, aldabra_core::Network::Mainnet, 0, 0) + .unwrap(); let root_b = RootKey::from_root_xsk_bech32(&decrypted).unwrap(); - let addr_b = aldabra_core::derive_base_address( - &root_b, - aldabra_core::Network::Mainnet, - 0, - 0, - ) - .unwrap(); + let addr_b = + aldabra_core::derive_base_address(&root_b, aldabra_core::Network::Mainnet, 0, 0) + .unwrap(); assert_eq!( addr_a, addr_b, "xprv import must derive the same address as mnemonic import" diff --git a/crates/aldabra-mcp/src/config.rs b/crates/aldabra-mcp/src/config.rs index ebf695a..1e00692 100644 --- a/crates/aldabra-mcp/src/config.rs +++ b/crates/aldabra-mcp/src/config.rs @@ -153,16 +153,18 @@ impl Config { .filter(|s| !s.trim().is_empty()); let account = match std::env::var("ALDABRA_ACCOUNT") { - Ok(s) => s - .parse::() - .map_err(|_| ConfigError::EnvParse { var: "ALDABRA_ACCOUNT", value: s })?, + Ok(s) => s.parse::().map_err(|_| ConfigError::EnvParse { + var: "ALDABRA_ACCOUNT", + value: s, + })?, Err(_) => file_cfg.account.unwrap_or(0), }; let index = match std::env::var("ALDABRA_INDEX") { - Ok(s) => s - .parse::() - .map_err(|_| ConfigError::EnvParse { var: "ALDABRA_INDEX", value: s })?, + Ok(s) => s.parse::().map_err(|_| ConfigError::EnvParse { + var: "ALDABRA_INDEX", + value: s, + })?, Err(_) => file_cfg.index.unwrap_or(0), }; @@ -216,9 +218,18 @@ mod tests { #[test] fn parse_network_accepts_canonical_names() { - assert!(matches!(parse_network("mainnet").unwrap(), Network::Mainnet)); - assert!(matches!(parse_network("Preview").unwrap(), Network::Preview)); - assert!(matches!(parse_network("PREPROD").unwrap(), Network::Preprod)); + assert!(matches!( + parse_network("mainnet").unwrap(), + Network::Mainnet + )); + assert!(matches!( + parse_network("Preview").unwrap(), + Network::Preview + )); + assert!(matches!( + parse_network("PREPROD").unwrap(), + Network::Preprod + )); } #[test] @@ -244,8 +255,7 @@ mod tests { assert_eq!(default_max_send_for(Network::Preprod), 100_000_000); assert_eq!(default_max_send_for(Network::Preview), 100_000_000); assert!( - default_max_send_for(Network::Mainnet) - < default_max_send_for(Network::Preprod), + default_max_send_for(Network::Mainnet) < default_max_send_for(Network::Preprod), "mainnet default must be strictly tighter than preprod" ); } diff --git a/crates/aldabra-mcp/src/main.rs b/crates/aldabra-mcp/src/main.rs index f82dcfd..ff2d3c4 100644 --- a/crates/aldabra-mcp/src/main.rs +++ b/crates/aldabra-mcp/src/main.rs @@ -107,14 +107,9 @@ async fn run() -> Result<()> { ); }; - let address = aldabra_core::derive_base_address( - &root, - cfg.network, - cfg.account, - cfg.index, - )?; - let payment_key = - aldabra_core::derive_payment_key(&root, cfg.account, cfg.index); + let address = + aldabra_core::derive_base_address(&root, cfg.network, cfg.account, cfg.index)?; + let payment_key = aldabra_core::derive_payment_key(&root, cfg.account, cfg.index); let stake_key = aldabra_core::derive_stake_key(&root, cfg.account); (payment_key, stake_key, address) // root drops here — XPrv::Drop wipes the 96 bytes diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 84d3dea..68de4ae 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -29,29 +29,6 @@ use std::path::PathBuf; use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; -use aldabra_dao::agora::stake::Credential as DaoCredential; -use aldabra_dao::builder::proposal_create::{ - build_unsigned_proposal_create, GovernorUtxoIn, ProposalCreateArgs, ReferenceUtxo, StakeUtxoIn, - WalletUtxo as DaoWalletUtxo, -}; -use aldabra_dao::builder::proposal_vote::{ - build_unsigned_proposal_vote, ProposalUtxoIn, ProposalVoteArgs, -}; -use aldabra_dao::builder::proposal_cosign::{ - build_unsigned_proposal_cosign, ProposalCosignArgs, -}; -use aldabra_dao::builder::proposal_advance::{ - build_unsigned_proposal_advance, AdvanceTransition, CosignerStakeRef, ProposalAdvanceArgs, -}; -use aldabra_dao::builder::proposal_retract_votes::{ - build_unsigned_proposal_retract_votes, ProposalRetractVotesArgs, -}; -use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs}; -use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; -use aldabra_dao::discovery::{ - apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER, -}; -use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD; use aldabra_core::{ add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata, @@ -61,6 +38,27 @@ use aldabra_core::{ PlutusInput, PlutusMintArgs as CorePlutusMintArgs, PlutusMintAsset, PlutusVersion, PolicySpec, ProtocolParams, ReferenceScriptSpec, ScriptKind, StakeKey, DEFAULT_EX_UNITS, }; +use aldabra_dao::agora::stake::Credential as DaoCredential; +use aldabra_dao::builder::proposal_advance::{ + build_unsigned_proposal_advance, AdvanceTransition, CosignerStakeRef, ProposalAdvanceArgs, +}; +use aldabra_dao::builder::proposal_cosign::{build_unsigned_proposal_cosign, ProposalCosignArgs}; +use aldabra_dao::builder::proposal_create::{ + build_unsigned_proposal_create, GovernorUtxoIn, ProposalCreateArgs, ReferenceUtxo, StakeUtxoIn, + WalletUtxo as DaoWalletUtxo, +}; +use aldabra_dao::builder::proposal_retract_votes::{ + build_unsigned_proposal_retract_votes, ProposalRetractVotesArgs, +}; +use aldabra_dao::builder::proposal_vote::{ + build_unsigned_proposal_vote, ProposalUtxoIn, ProposalVoteArgs, +}; +use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs}; +use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; +use aldabra_dao::discovery::{ + apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER, +}; +use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; /// Resolve a reference-script bytestring from EITHER an inline hex /// argument OR a file path inside the container. Caller passes both @@ -77,9 +75,9 @@ fn resolve_ref_script_bytes( path: Option<&str>, ) -> Result>, String> { match (cbor_hex, path) { - (Some(_), Some(_)) => Err( - "set at most one of reference_script_cbor_hex / reference_script_path".into(), - ), + (Some(_), Some(_)) => { + Err("set at most one of reference_script_cbor_hex / reference_script_path".into()) + } (Some(s), None) => { let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect(); Ok(Some(hex_decode(&cleaned).map_err(|e| { @@ -119,9 +117,7 @@ fn resolve_policy_cbor_bytes( path: Option<&str>, ) -> Result, String> { match (cbor_hex, path) { - (Some(_), Some(_)) => Err( - "set at most one of policy_cbor_hex / policy_cbor_path".into(), - ), + (Some(_), Some(_)) => Err("set at most one of policy_cbor_hex / policy_cbor_path".into()), (Some(s), None) => { let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect(); hex_decode(&cleaned).map_err(|e| format!("decode policy_cbor_hex: {e}")) @@ -135,13 +131,9 @@ fn resolve_policy_cbor_bytes( "policy_cbor_path '{p}' contained no hex characters" )); } - hex_decode(&cleaned).map_err(|e| { - format!("decode policy_cbor_path '{p}' contents: {e}") - }) - } - (None, None) => { - Err("must set exactly one of policy_cbor_hex / policy_cbor_path".into()) + hex_decode(&cleaned).map_err(|e| format!("decode policy_cbor_path '{p}' contents: {e}")) } + (None, None) => Err("must set exactly one of policy_cbor_hex / policy_cbor_path".into()), } } @@ -278,8 +270,8 @@ impl WalletService { /// `StakeDatum.owner`. Returns the 28-byte pkh. fn wallet_pkh(&self) -> Result, String> { use pallas_addresses::{Address, ShelleyPaymentPart}; - let addr = Address::from_bech32(&self.inner.address) - .map_err(|e| format!("address parse: {e}"))?; + let addr = + Address::from_bech32(&self.inner.address).map_err(|e| format!("address parse: {e}"))?; match addr { Address::Shelley(s) => match s.payment() { ShelleyPaymentPart::Key(h) => Ok(h.as_ref().to_vec()), @@ -342,10 +334,10 @@ pub struct SendArgs { /// `reference_script_cbor_hex` for scripts >~ 4KB to bypass the /// MCP large-string transport bug (caught 2026-05-07: hex strings /// > ~4500 chars get a 1-byte truncation + structural rearrangement - /// somewhere between Claude Code and aldabra's stdio reader). - /// File contents may include leading/trailing whitespace; only - /// hex chars are decoded. At most one of `reference_script_cbor_hex` - /// or `reference_script_path` may be set. + /// > somewhere between Claude Code and aldabra's stdio reader). + /// > File contents may include leading/trailing whitespace; only + /// > hex chars are decoded. At most one of `reference_script_cbor_hex` + /// > or `reference_script_path` may be set. #[serde(default)] pub reference_script_path: Option, /// Plutus version of the reference-script: "PlutusV1", "PlutusV2", @@ -472,11 +464,11 @@ pub struct PlutusMintUnsignedArgs { /// `policy_cbor_hex` for scripts >~ 4500 chars to bypass the /// MCP large-string transport bug (caught 2026-05-07: hex strings /// > ~4500 chars get a 1-byte truncation + structural rearrangement - /// somewhere between Claude Code and aldabra's stdio reader, - /// surfacing as "odd length" hex decode errors). File contents - /// may include leading/trailing whitespace; only hex chars are - /// decoded. At most one of `policy_cbor_hex` or `policy_cbor_path` - /// may be set; exactly one must be set. + /// > somewhere between Claude Code and aldabra's stdio reader, + /// > surfacing as "odd length" hex decode errors). File contents + /// > may include leading/trailing whitespace; only hex chars are + /// > decoded. At most one of `policy_cbor_hex` or `policy_cbor_path` + /// > may be set; exactly one must be set. #[serde(default)] pub policy_cbor_path: Option, /// Plutus version: "v1", "v2", or "v3". @@ -891,10 +883,14 @@ impl WalletService { cbor: bytes.as_slice(), }), (Some(_), None) => { - return Err("reference_script_cbor_hex/path set without reference_script_kind".into()) + return Err( + "reference_script_cbor_hex/path set without reference_script_kind".into(), + ) } (None, Some(_)) => { - return Err("reference_script_kind set without reference_script_cbor_hex/path".into()) + return Err( + "reference_script_kind set without reference_script_cbor_hex/path".into(), + ) } (None, None) => None, }; @@ -995,10 +991,14 @@ impl WalletService { cbor: bytes.as_slice(), }), (Some(_), None) => { - return Err("reference_script_cbor_hex/path set without reference_script_kind".into()) + return Err( + "reference_script_cbor_hex/path set without reference_script_kind".into(), + ) } (None, Some(_)) => { - return Err("reference_script_kind set without reference_script_cbor_hex/path".into()) + return Err( + "reference_script_kind set without reference_script_cbor_hex/path".into(), + ) } (None, None) => None, }; @@ -1637,8 +1637,7 @@ impl WalletService { // Resolve PolicySpec — caller-supplied JSON or wallet default. let policy_spec: PolicySpec = match policy { - Some(v) => serde_json::from_value(v) - .map_err(|e| format!("policy: {e}"))?, + Some(v) => serde_json::from_value(v).map_err(|e| format!("policy: {e}"))?, None => PolicySpec::single_sig(&self.inner.payment_key), }; @@ -1662,10 +1661,7 @@ impl WalletService { .await .map_err(|e| format!("fetch utxos: {e}"))?; if utxos.is_empty() { - return Err(format!( - "no utxos at wallet address {}", - self.inner.address - )); + return Err(format!("no utxos at wallet address {}", self.inner.address)); } let inputs: Vec = utxos .into_iter() @@ -1724,10 +1720,8 @@ impl WalletService { )); } - let policy_cbor = resolve_policy_cbor_bytes( - policy_cbor_hex.as_deref(), - policy_cbor_path.as_deref(), - )?; + let policy_cbor = + resolve_policy_cbor_bytes(policy_cbor_hex.as_deref(), policy_cbor_path.as_deref())?; let redeemer_cbor = hex_decode(&redeemer_cbor_hex).map_err(|e| format!("decode redeemer: {e}"))?; let policy_ver = match policy_version.trim().to_ascii_lowercase().as_str() { @@ -1774,10 +1768,7 @@ impl WalletService { .await .map_err(|e| format!("fetch utxos: {e}"))?; if utxos.is_empty() { - return Err(format!( - "no utxos at wallet address {}", - self.inner.address - )); + return Err(format!("no utxos at wallet address {}", self.inner.address)); } let inputs: Vec = utxos .into_iter() @@ -1793,7 +1784,9 @@ impl WalletService { let (h, ix) = r .split_once('#') .ok_or_else(|| format!("required_input_ref '{r}' must be 'txhash#index'"))?; - let ix: u32 = ix.parse().map_err(|e| format!("required_input_ref idx: {e}"))?; + let ix: u32 = ix + .parse() + .map_err(|e| format!("required_input_ref idx: {e}"))?; let found = inputs .iter() .find(|u| u.tx_hash_hex == h && u.output_index == ix) @@ -1865,8 +1858,8 @@ impl WalletService { #[tool(aggr)] SignPartialArgs { cbor_hex }: SignPartialArgs, ) -> Result { let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?; - let updated = add_witness(&self.inner.payment_key, &bytes) - .map_err(|e| format!("sign: {e}"))?; + let updated = + add_witness(&self.inner.payment_key, &bytes).map_err(|e| format!("sign: {e}"))?; let mut hex = String::with_capacity(updated.len() * 2); for b in &updated { hex.push_str(&format!("{:02x}", b)); @@ -2099,12 +2092,13 @@ impl WalletService { description = "List all registered DAO config names (sorted) plus the currently active one. Returns JSON {active: \"\"|null, all: [...]}." )] async fn dao_list(&self) -> Result { - let all = self + let all = self.inner.dao_store.list().map_err(|e| e.to_string())?; + let active = self .inner .dao_store - .list() - .map_err(|e| e.to_string())?; - let active = self.inner.dao_store.get_active().ok().map(|a| a.name().to_string()); + .get_active() + .ok() + .map(|a| a.name().to_string()); Ok(serde_json::json!({ "active": active, "all": all }).to_string()) } @@ -2219,10 +2213,8 @@ impl WalletService { .list_stakes(&cfg) .await .map_err(|e| e.to_string())?; - let arr: Vec = stakes - .into_iter() - .map(|s| stake_utxo_to_json(&s)) - .collect(); + let arr: Vec = + stakes.into_iter().map(|s| stake_utxo_to_json(&s)).collect(); Ok(serde_json::json!({ "dao": cfg.name, "stakes": arr }).to_string()) } @@ -2316,7 +2308,9 @@ impl WalletService { .map_err(|e| format!("koios get governor utxos: {e}"))? .into_iter() .find(|u| u.tx_hash == gov_tx_hash && u.output_index == gov_idx) - .ok_or_else(|| format!("governor utxo {governor_utxo_ref} no longer present on chain"))?; + .ok_or_else(|| { + format!("governor utxo {governor_utxo_ref} no longer present on chain") + })?; let gov_lovelace = governor_utxo.lovelace; // Extract GST policy + name from the governor utxo's asset_list. // Sulkta's GST has empty asset name; one asset on the utxo (qty=1) IS the GST. @@ -2444,33 +2438,28 @@ impl WalletService { }; // ScriptRefs must be populated before this tool can build a tx. - let governor_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .governor_validator - .as_deref() - .ok_or_else(|| { + let governor_validator_ref = + ReferenceUtxo::from_str(cfg.script_refs.governor_validator.as_deref().ok_or_else( + || { "DaoConfig.script_refs.governor_validator missing — \ - run dao_discover_scripts first".to_string() - })?, - ) - .map_err(|e| e.to_string())?; + run dao_discover_scripts first" + .to_string() + }, + )?) + .map_err(|e| e.to_string())?; let stake_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .stake_validator - .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.stake_validator missing — \ - run dao_discover_scripts first".to_string() - })?, + cfg.script_refs.stake_validator.as_deref().ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first" + .to_string() + })?, ) .map_err(|e| e.to_string())?; let proposal_st_policy_ref = ReferenceUtxo::from_str( cfg.script_refs .proposal_st_policy .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.proposal_st_policy missing".to_string() - })?, + .ok_or_else(|| "DaoConfig.script_refs.proposal_st_policy missing".to_string())?, ) .map_err(|e| e.to_string())?; @@ -2581,23 +2570,19 @@ impl WalletService { let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?; let stake_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .stake_validator - .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.stake_validator missing — \ - run dao_discover_scripts first".to_string() - })?, + cfg.script_refs.stake_validator.as_deref().ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first" + .to_string() + })?, ) .map_err(|e| e.to_string())?; let stake_st_policy_ref = ReferenceUtxo::from_str( - cfg.script_refs - .stake_st_policy - .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.stake_st_policy missing — \ - run dao_discover_scripts first".to_string() - })?, + cfg.script_refs.stake_st_policy.as_deref().ok_or_else(|| { + "DaoConfig.script_refs.stake_st_policy missing — \ + run dao_discover_scripts first" + .to_string() + })?, ) .map_err(|e| e.to_string())?; @@ -2700,7 +2685,8 @@ impl WalletService { // PWithin, AND gate Locked→Finished on tx_lower > executing_end so // we never hit the "missing GAT-mint" path. use aldabra_dao::agora::proposal::ProposalStatus as PS; - const VALIDITY_RANGE_MS: i64 = aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS as i64 * 1000; + const VALIDITY_RANGE_MS: i64 = + aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS as i64 * 1000; let tx_lower_ms = tip_ms; let tx_upper_ms = tip_ms + VALIDITY_RANGE_MS; let st = target.datum.starting_time; @@ -2716,7 +2702,7 @@ impl WalletService { // straddle a phase boundary — e.g. early Draft→VotingReady // advance with the wide 1799-slot range ends 30min past // starting_time, way past drafting_end on a 30-min DAO. - let mut valid_from_slot_override: Option = None; + let valid_from_slot_override: Option = None; let mut invalid_from_slot_override: Option = None; let transition = match target.datum.status { @@ -2849,16 +2835,15 @@ impl WalletService { } } - let proposal_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .proposal_validator - .as_deref() - .ok_or_else(|| { + let proposal_validator_ref = + ReferenceUtxo::from_str(cfg.script_refs.proposal_validator.as_deref().ok_or_else( + || { "DaoConfig.script_refs.proposal_validator missing — \ - run dao_discover_scripts first".to_string() - })?, - ) - .map_err(|e| e.to_string())?; + run dao_discover_scripts first" + .to_string() + }, + )?) + .map_err(|e| e.to_string())?; let advancer_pkh = self.wallet_pkh()?; let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?; @@ -3006,25 +2991,22 @@ impl WalletService { // ScriptRefs. let stake_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .stake_validator - .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.stake_validator missing — \ - run dao_discover_scripts first".to_string() - })?, + cfg.script_refs.stake_validator.as_deref().ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first" + .to_string() + })?, ) .map_err(|e| e.to_string())?; - let proposal_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .proposal_validator - .as_deref() - .ok_or_else(|| { + let proposal_validator_ref = + ReferenceUtxo::from_str(cfg.script_refs.proposal_validator.as_deref().ok_or_else( + || { "DaoConfig.script_refs.proposal_validator missing — \ - run dao_discover_scripts first".to_string() - })?, - ) - .map_err(|e| e.to_string())?; + run dao_discover_scripts first" + .to_string() + }, + )?) + .map_err(|e| e.to_string())?; let unsigned = build_unsigned_proposal_cosign(ProposalCosignArgs { cfg: cfg.clone(), @@ -3101,7 +3083,10 @@ impl WalletService { .into_iter() .find(|p| p.datum.proposal_id == proposal_id) .ok_or_else(|| { - format!("no proposal with proposal_id={} found at {}", proposal_id, proposal_addr) + format!( + "no proposal with proposal_id={} found at {}", + proposal_id, proposal_addr + ) })?; let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?; let prop_lovelace = target.lovelace; @@ -3173,8 +3158,8 @@ impl WalletService { .and_then(|t| t.get("abs_slot")) .and_then(|s| s.as_u64()) .ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?; - let default_validity_upper_slot = tip_slot - + aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS; + let default_validity_upper_slot = + tip_slot + aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS; let tx_lower_ms = slot_to_posix_ms(cfg.network, tip_slot)?; // AUDIT-2026-05-06 H-3 fix: validator (Proposal/Scripts.hs PVote @@ -3193,10 +3178,8 @@ impl WalletService { // proposal_advance Draft→VotingReady clamp uses. // // Read from prop_datum (target.datum was moved to prop_datum at L2636). - let voting_start_check = prop_datum.starting_time - + prop_datum.timing_config.draft_time; - let voting_end_check = voting_start_check - + prop_datum.timing_config.voting_time; + let voting_start_check = prop_datum.starting_time + prop_datum.timing_config.draft_time; + let voting_end_check = voting_start_check + prop_datum.timing_config.voting_time; if tx_lower_ms < voting_start_check { return Err(format!( "tx lower bound {tx_lower_ms} ms is before voting window start {voting_start_check} ms \ @@ -3259,25 +3242,22 @@ impl WalletService { // ScriptRefs: stake + proposal validators. let stake_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .stake_validator - .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.stake_validator missing — \ - run dao_discover_scripts first".to_string() - })?, + cfg.script_refs.stake_validator.as_deref().ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first" + .to_string() + })?, ) .map_err(|e| e.to_string())?; - let proposal_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .proposal_validator - .as_deref() - .ok_or_else(|| { + let proposal_validator_ref = + ReferenceUtxo::from_str(cfg.script_refs.proposal_validator.as_deref().ok_or_else( + || { "DaoConfig.script_refs.proposal_validator missing — \ - run dao_discover_scripts first".to_string() - })?, - ) - .map_err(|e| e.to_string())?; + run dao_discover_scripts first" + .to_string() + }, + )?) + .map_err(|e| e.to_string())?; let unsigned = build_unsigned_proposal_vote(ProposalVoteArgs { cfg: cfg.clone(), @@ -3355,7 +3335,10 @@ impl WalletService { .into_iter() .find(|p| p.datum.proposal_id == proposal_id) .ok_or_else(|| { - format!("no proposal with proposal_id={} found at {}", proposal_id, proposal_addr) + format!( + "no proposal with proposal_id={} found at {}", + proposal_id, proposal_addr + ) })?; let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?; let prop_lovelace = target.lovelace; @@ -3465,25 +3448,22 @@ impl WalletService { // Reference UTxOs — same pattern as vote. let stake_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .stake_validator - .as_deref() - .ok_or_else(|| { - "DaoConfig.script_refs.stake_validator missing — \ - run dao_discover_scripts first".to_string() - })?, + cfg.script_refs.stake_validator.as_deref().ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first" + .to_string() + })?, ) .map_err(|e| e.to_string())?; - let proposal_validator_ref = ReferenceUtxo::from_str( - cfg.script_refs - .proposal_validator - .as_deref() - .ok_or_else(|| { + let proposal_validator_ref = + ReferenceUtxo::from_str(cfg.script_refs.proposal_validator.as_deref().ok_or_else( + || { "DaoConfig.script_refs.proposal_validator missing — \ - run dao_discover_scripts first".to_string() - })?, - ) - .map_err(|e| e.to_string())?; + run dao_discover_scripts first" + .to_string() + }, + )?) + .map_err(|e| e.to_string())?; let unsigned = build_unsigned_proposal_retract_votes(ProposalRetractVotesArgs { cfg: cfg.clone(), @@ -3595,7 +3575,6 @@ pub struct DaoRegisterArgs { // upcoming vote/cosign/advance tools. Each can be discovered via // chain queries (the audit pattern at memory/audit-sulkta-agora-*.md); // a future dao_discover_scripts MCP tool will fill them automatically. - /// Proposal validator address (bech32). Where new proposal UTxOs land. #[serde(default)] pub proposal_addr: Option, @@ -3776,15 +3755,14 @@ fn slot_to_posix_ms(network: DaoNetwork, slot: u64) -> Result { )); } let delta_slots = slot - slot_zero; - let delta_ms = (delta_slots as i64).checked_mul(1000).ok_or_else(|| { - format!("slot delta {delta_slots} * 1000 overflows i64") - })?; + let delta_ms = (delta_slots as i64) + .checked_mul(1000) + .ok_or_else(|| format!("slot delta {delta_slots} * 1000 overflows i64"))?; posix_ms_zero .checked_add(delta_ms) .ok_or_else(|| "posix_ms add overflow".into()) } - /// Pull wallet UTxOs with H-5 strict asset-key parsing. /// /// Shared by every DAO write-path tool that needs to fund + collateralize @@ -3831,11 +3809,12 @@ fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> { let (h, i) = s .split_once('#') .ok_or_else(|| format!("utxo ref {s:?} not in 'txhash#index' form"))?; - let idx: u32 = i.parse().map_err(|e| format!("utxo index {i:?} parse: {e}"))?; + let idx: u32 = i + .parse() + .map_err(|e| format!("utxo index {i:?} parse: {e}"))?; Ok((h.to_string(), idx)) } - /// Render a [`aldabra_dao::reader::StakeUtxo`] as a JSON object for tool output. /// /// Formatted as a free function rather than `impl Serialize for StakeUtxo` to @@ -3858,7 +3837,10 @@ fn stake_utxo_to_json(s: &aldabra_dao::reader::StakeUtxo) -> serde_json::Value { .map(|l| { let action = match &l.action { ProposalAction::Created => serde_json::json!({"kind":"Created"}), - ProposalAction::Voted { result_tag, posix_time } => serde_json::json!({ + ProposalAction::Voted { + result_tag, + posix_time, + } => serde_json::json!({ "kind":"Voted","result_tag": result_tag, "posix_time_ms": posix_time, }), ProposalAction::Cosigned => serde_json::json!({"kind":"Cosigned"}),