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:
parent
0987d5de12
commit
03b5efb3b2
35 changed files with 1125 additions and 1072 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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_live_stake_datum() {
|
||||
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 first 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_live_stake_datum_b() {
|
||||
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 second 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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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}")))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue