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:
Sulkta 2026-05-09 10:27:48 -07:00
parent 0987d5de12
commit 03b5efb3b2
35 changed files with 1125 additions and 1072 deletions

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 internal notes*.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"}),