audit: cargo fmt + clippy --fix across workspace + retract_votes cooldown bug fix

Surfaced by Track #38 code audit (2026-05-09):

1. cargo fmt --all: 217 formatting diffs across 35 files. Pure
   whitespace; no semantic changes.

2. cargo clippy --fix: 30 warnings -> 10. Auto-applied:
   - useless format!() (3 sites in builder/proposal_*.rs)
   - needless_borrow_for_generic_args (4 sites)
   - cloned_ref_to_slice_refs (1 site, builder/proposal_cosign.rs)
   - derivable_impls (1 site, dao/config.rs)
   - unused imports/variables (3 sites)

   Remaining 10 warnings are non-trivial (too_many_arguments on a
   constructor at 8 args, FromStr trait shadow, doc_lazy_continuation
   on a few comment blocks). Filed as tech-debt; no action this pass.

3. cargo audit: 0 vulnerabilities. 2 unmaintained advisories on
   transitive deps:
   - paste 1.0.15 (RUSTSEC-2024-0436) via rmcp + pallas-traverse
   - proc-macro-error 1.0.4 (RUSTSEC-2024-0370) via age->i18n-embed-fl
   Both upstream; tracked but no action needed locally.

4. Test failure surfaced: builder::proposal_retract_votes::tests::
   voting_ready_in_window_subtracts_vote_weight failed — cooldown
   check was applied unconditionally for RemoveVoterLockOnly mode,
   blocking the legitimate 'retract during voting window' path
   where the proposal datum mutates (vote weight subtraction). Per
   Agora's premoveLocks rule, cooldown only applies when retracting
   AFTER voting closed but BEFORE Finished — not during the active
   voting window. Fixed by gating cooldown on
   '!proposal_datum_will_change' so the in-window retract path
   bypasses cooldown the same way RemoveAllLocks does.

   Test: 87/87 aldabra-dao lib tests pass post-fix (was 86/87).
This commit is contained in:
Kayos 2026-05-09 10:27:48 -07:00
parent 8af9f2e238
commit c7f7dcb102
35 changed files with 1125 additions and 1072 deletions

65
Cargo.lock generated
View file

@ -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"

View file

@ -269,7 +269,9 @@ impl ChainBackend for KoiosClient {
}
async fn get_balance(&self, address: &str) -> Result<Balance, ChainError> {
let body = AddressesBody { addresses: vec![address] };
let body = AddressesBody {
addresses: vec![address],
};
let raw: Vec<KoiosAddressInfo> = 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<TxStatus, ChainError> {
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
let body = TxHashesBody {
tx_hashes: vec![tx_hash],
};
let raw: Vec<KoiosTxStatusResp> = 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<KoiosUtxo> = serde_json::from_str(SAMPLE_UTXOS).unwrap();
let utxos: Vec<Utxo> = raw.into_iter().map(convert_utxo).collect::<Result<_, _>>().unwrap();
let utxos: Vec<Utxo> = raw
.into_iter()
.map(convert_utxo)
.collect::<Result<_, _>>()
.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()
);
}
}

View file

@ -136,8 +136,7 @@ fn json_to_plutus_data(v: &Value) -> Result<PlutusData, WalletError> {
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<Vec<u8>, 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 {

View file

@ -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<String, crate::WalletError> {
pub fn stake_address(&self, network: crate::Network) -> Result<String, crate::WalletError> {
use pallas_addresses::{StakeAddress, StakePayload};
let payload = StakePayload::Stake(self.public_key_hash());
let addr = StakeAddress::new(network.to_pallas(), payload);

View file

@ -89,8 +89,8 @@ pub fn parse_drep_target(s: &str) -> Result<DRepTarget, WalletError> {
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<DRepTarget, WalletError> {
}
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
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<Hash<32>, 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<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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(),

View file

@ -243,10 +243,8 @@ pub fn summarize_tx(cbor_bytes: &[u8]) -> Result<TxSummary, WalletError> {
.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()),

View file

@ -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<RootKey, WalletError> {
pub fn into_root_key_with_passphrase(self, passphrase: &str) -> Result<RootKey, WalletError> {
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);

View file

@ -74,7 +74,11 @@ fn json_to_metadatum(v: &Value) -> Result<Metadatum, WalletError> {
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<Vec<u8>, WalletError> {

View file

@ -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, WalletError> {
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<Hash<32>, 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<StagingTransaction, WalletError> {
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<String, u64> = 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<StagingTransaction, WalletError> {
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<String, u64> = 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"

View file

@ -75,8 +75,7 @@ impl PlutusVersion {
pub const MIN_COLLATERAL_LOVELACE: u64 = 5_000_000;
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
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<Hash<32>, 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<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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)
}

View file

@ -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,
];

View file

@ -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<String, u64> = 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<String, u64> = 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<Input> = 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::<Result<_, _>>()?;
let build_with_fee = |fee: u64,
change_lovelace: u64|
-> Result<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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<String, u64> = 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<String, u64> = 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)

View file

@ -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<Vec<u8>, WalletError> {
pub fn add_witness(payment_key: &PaymentKey, cbor_bytes: &[u8]) -> Result<Vec<u8>, 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)
}

View file

@ -52,8 +52,7 @@ pub fn parse_pool_id(bech32_str: &str) -> Result<Hash<28>, WalletError> {
}
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
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<Hash<32>, WalletError> {
@ -156,50 +155,51 @@ pub fn build_signed_stake_delegation(
}
}
let build_with_fee = |fee: u64,
change_lovelace: u64|
-> Result<StagingTransaction, WalletError> {
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<StagingTransaction, WalletError> {
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={}",

View file

@ -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<Vec<u8>, WalletError> {
fn build_unsigned_bytes(staging: StagingTransaction) -> Result<Vec<u8>, 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]

View file

@ -45,9 +45,7 @@ fn find_subseq(haystack: &[u8], needle: &[u8]) -> Option<usize> {
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

View file

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

View file

@ -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};

View file

@ -70,7 +70,9 @@ pub fn as_product(pd: &PlutusData) -> DaoResult<&Vec<PlutusData>> {
/// none currently do.
pub fn int(n: i128) -> DaoResult<PlutusData> {
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<PlutusData>)> {
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<i128> {
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<Vec<u8>> {
/// Decode a PlutusData::Array (works for both Def and Indef encodings).
pub fn as_array(pd: &PlutusData) -> DaoResult<&Vec<PlutusData>> {
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<PlutusData>> {
pub fn as_map(pd: &PlutusData) -> DaoResult<Vec<(&PlutusData, &PlutusData)>> {
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:?}"))),
}
}

View file

@ -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<PlutusData> {
let cosigners_pd: Vec<PlutusData> = self
.cosigners
.iter()
.map(|c| c.to_plutus_data())
.collect();
let cosigners_pd: Vec<PlutusData> =
self.cosigners.iter().map(|c| c.to_plutus_data()).collect();
Ok(product(vec![
int(self.proposal_id as i128)?,
self.effects_raw.clone(),

View file

@ -78,7 +78,10 @@ impl ProposalAction {
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
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<Credential> expects Constr 0[1] | 1[0], got Constr {j} with {} fields",
f.len()
)))
}
_ => return Err(DaoError::Datum(format!(
"Maybe<Credential> 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 {

View file

@ -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;

View file

@ -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<u8> { vec![0x10; 28] }
fn pkh_b() -> Vec<u8> { vec![0x80; 28] }
fn pkh_a() -> Vec<u8> {
vec![0x10; 28]
}
fn pkh_b() -> Vec<u8> {
vec![0x80; 28]
}
fn advancer_pkh() -> Vec<u8> {
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,

View file

@ -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<u8> { vec![0x10u8; 28] }
fn other_pkh_b() -> Vec<u8> { vec![0xf0u8; 28] }
fn other_pkh_a() -> Vec<u8> {
vec![0x10u8; 28]
}
fn other_pkh_b() -> Vec<u8> {
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"));
}

View file

@ -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<Hash<32>> {
}
pub(super) fn parse_script_hash(hex_str: &str) -> DaoResult<Hash<28>> {
let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("script_hash hex: {e}")))?;
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 {}",

View file

@ -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]

View file

@ -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<UnsignedProposalVote> {
pub fn build_unsigned_proposal_vote(args: ProposalVoteArgs) -> DaoResult<UnsignedProposalVote> {
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::<Vec<_>>(),
args.proposal
.datum
.votes
.0
.iter()
.map(|(k, _)| *k)
.collect::<Vec<_>>(),
))
})?;
@ -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]

View file

@ -71,9 +71,7 @@ pub struct UnsignedStakeDestroy {
pub summary: String,
}
pub fn build_unsigned_stake_destroy(
args: StakeDestroyArgs,
) -> DaoResult<UnsignedStakeDestroy> {
pub fn build_unsigned_stake_destroy(args: StakeDestroyArgs) -> DaoResult<UnsignedStakeDestroy> {
// ---- 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);

View file

@ -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<DaoConfig> {
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<ActiveDao> {
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,

View file

@ -70,8 +70,7 @@ impl KoiosDiscoveryClient {
/// <token>` 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<String>, 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<Vec<AddressInfo>> {
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(),
},
]),

View file

@ -100,8 +100,7 @@ impl KoiosDaoReader {
/// <token>` 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<String>, 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<PlutusData> {
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::<PlutusData>(&bytes)
.map_err(|e| DaoError::Cbor(format!("plutus data decode: {e}")))
}

View file

@ -200,8 +200,7 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
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<RootKey> {
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<RootKey> {
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"

View file

@ -153,16 +153,18 @@ impl Config {
.filter(|s| !s.trim().is_empty());
let account = match std::env::var("ALDABRA_ACCOUNT") {
Ok(s) => s
.parse::<u32>()
.map_err(|_| ConfigError::EnvParse { var: "ALDABRA_ACCOUNT", value: s })?,
Ok(s) => s.parse::<u32>().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::<u32>()
.map_err(|_| ConfigError::EnvParse { var: "ALDABRA_INDEX", value: s })?,
Ok(s) => s.parse::<u32>().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"
);
}

View file

@ -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

View file

@ -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<Option<Vec<u8>>, 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<Vec<u8>, 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<Vec<u8>, 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<String>,
/// 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<String>,
/// 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<InputUtxo> = 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<InputUtxo> = 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<String, String> {
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: \"<name>\"|null, all: [...]}."
)]
async fn dao_list(&self) -> Result<String, String> {
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<serde_json::Value> = stakes
.into_iter()
.map(|s| stake_utxo_to_json(&s))
.collect();
let arr: Vec<serde_json::Value> =
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<u64> = None;
let valid_from_slot_override: Option<u64> = None;
let mut invalid_from_slot_override: Option<u64> = 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<String>,
@ -3776,15 +3755,14 @@ fn slot_to_posix_ms(network: DaoNetwork, slot: u64) -> Result<i64, String> {
));
}
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"}),