fix(dao): audit punch list — H-1 to H-4 + M-2 + pallas bump

Lands the high-priority fixes from the 2026-05-06 audit before any
mainnet submit of the new vote/cosign/advance/destroy txs.

## H-1: Locked→Finished gate (MCP tool)

`dao_proposal_advance_unsigned` now refuses LockedToFinished unless
`tx_lower_ms > executing_end`. During the executing period the
validator demands gstMoved=true (governor input present); builder
doesn't include the governor input, so a tx in that window would
fee-burn. The proper Locked→Finished + GAT-mint flow is Phase 4c-bis;
this gate keeps us out of the broken middle.

## H-2 + H-4: strict-boundary + tx-upper-inside-period (MCP tool)

Validator's pgetRelation is strict on PAfter (`period_end < lb`)
and demands `ub <= period_end` on PWithin. Tool now picks PWithin
only when `tx_lower_ms >= period_start && tx_upper_ms <= period_end`,
PAfter only when `tx_lower_ms > period_end` strictly, and explicit-
errors on the boundary-straddling case (when tx validity range
crosses out of the target period). Same logic mirrored for the
VotingReady→Locked + VotingReady→Finished branches.

## H-3: vote builder lower-bound preflight (MCP tool)

`dao_proposal_vote_unsigned` previously checked only validity_upper
vs voting_end_ms. Validator demands BOTH `voting_start <= lb` AND
`ub <= voting_end`. Vote-too-early would hit "too early or invalid"
script error. New preflight on tx_lower_ms vs voting_start.

## M-2: DRep deposit pulled from ProtocolParams

Hardcoded constant (500 ADA) was wrong if the protocol changes
drep_deposit OR if the DRep was originally registered at a different
deposit amount (deregistration must match registration). Added
`drep_deposit_lovelace: u64` to ProtocolParams (default 500 ADA),
governance.rs build_signed_drep_registration / deregistration now
read from params instead of the constant. Constant kept for
backward compat with a doc note pointing at the params field.

## Pallas fork bump 507fd9da → 8091abd1

M-4 from the audit landed on the fork: voting_procedures builder
debug_assert_ne!s against empty CBOR map (0xa0) and docs the
upstream NonEmptyKeyValuePairs::decode footgun.

L-1 from the audit was a false finding — the audit subagent
misread the constants. PROPOSAL_CREATE_*_EX_UNITS are already at
the post-2026-05-05-H-2 values (5M mem / 2G steps per spend, 2M / 1G
per mint). The new builders alias these correctly. No change needed.
This commit is contained in:
Kayos 2026-05-06 08:06:44 -07:00
parent b93bda75c9
commit a0daadf38e
4 changed files with 110 additions and 26 deletions

14
Cargo.lock generated
View file

@ -1253,7 +1253,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "pallas-addresses"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"base58",
"bech32",
@ -1268,7 +1268,7 @@ dependencies = [
[[package]]
name = "pallas-codec"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"hex",
"minicbor",
@ -1279,7 +1279,7 @@ dependencies = [
[[package]]
name = "pallas-crypto"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"cryptoxide",
"hex",
@ -1293,7 +1293,7 @@ dependencies = [
[[package]]
name = "pallas-primitives"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"base58",
"bech32",
@ -1308,7 +1308,7 @@ dependencies = [
[[package]]
name = "pallas-traverse"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"hex",
"itertools",
@ -1324,7 +1324,7 @@ dependencies = [
[[package]]
name = "pallas-txbuilder"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"hex",
"pallas-addresses",
@ -1341,7 +1341,7 @@ dependencies = [
[[package]]
name = "pallas-wallet"
version = "0.32.1"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
dependencies = [
"bech32",
"bip39",

View file

@ -36,8 +36,11 @@ use crate::tx::InputUtxo;
use crate::{Network, PaymentKey, ProtocolParams, StakeKey, WalletError};
/// Conway DRep registration deposit. Mainnet protocol parameter
/// `drep_deposit` is currently 500 ADA. Caller can pass an override
/// via `params` if a hardfork changes it; default constant here.
/// `drep_deposit` is currently 500 ADA. **Use `params.drep_deposit_lovelace`
/// instead of this constant** — it's kept here for backward-compat callers
/// only. AUDIT-2026-05-06 M-2: hardcoding the deposit means a protocol
/// change (or an old DRep registered at a different deposit) will silently
/// fail ledger validation. Always pull from current chain params.
pub const DREP_REGISTRATION_DEPOSIT_LOVELACE: u64 = 500_000_000;
/// Two witnesses (payment + stake) — same overhead as
@ -263,7 +266,7 @@ pub fn build_signed_drep_registration(
}
};
let cert = Certificate::RegDRepCert(drep_credential, DREP_REGISTRATION_DEPOSIT_LOVELACE, anchor);
let cert = Certificate::RegDRepCert(drep_credential, params.drep_deposit_lovelace, anchor);
let cert_bytes = minicbor::to_vec(&cert)
.map_err(|e| WalletError::Derivation(format!("encode RegDRep cert: {e}")))?;
@ -274,7 +277,7 @@ pub fn build_signed_drep_registration(
available_utxos,
change_address_bech32,
vec![cert_bytes],
DREP_REGISTRATION_DEPOSIT_LOVELACE,
params.drep_deposit_lovelace,
params,
)
}
@ -483,16 +486,15 @@ pub fn build_signed_drep_deregistration(
) -> Result<Vec<u8>, WalletError> {
let stake_pkh = stake_key.public_key_hash();
let drep_credential = StakeCredential::AddrKeyhash(stake_pkh);
let cert = Certificate::UnRegDRepCert(drep_credential, DREP_REGISTRATION_DEPOSIT_LOVELACE);
let cert = Certificate::UnRegDRepCert(drep_credential, params.drep_deposit_lovelace);
let cert_bytes = minicbor::to_vec(&cert)
.map_err(|e| WalletError::Derivation(format!("encode UnRegDRep cert: {e}")))?;
// Negative deposit — we get it back. Two-pass fee accounts for it
// by leaving `deposit` at 0 here and letting the wallet output absorb
// the refund. Note: pallas-txbuilder writes the deposit as a negative
// contribution implicitly via the cert; the change calc here just
// needs to know we DON'T owe the protocol anything. Caller should
// expect their wallet output to grow by 500 ADA - fee.
// Refund equals the deposit originally paid. Critical: this MUST match
// what the DRep was originally registered with, not "current chain
// drep_deposit." If the protocol changed deposit between registration
// and deregistration, caller needs to override `params.drep_deposit_lovelace`
// to the original-registration value. Otherwise ledger silently fails.
sign_cert_tx_with_refund(
payment_key,
stake_key,
@ -500,7 +502,7 @@ pub fn build_signed_drep_deregistration(
available_utxos,
change_address_bech32,
vec![cert_bytes],
DREP_REGISTRATION_DEPOSIT_LOVELACE,
params.drep_deposit_lovelace,
params,
)
}

View file

@ -79,6 +79,11 @@ pub struct ProtocolParams {
/// `None`, Plutus paths skip script_data_hash and the chain will
/// reject with `PPViewHashesDontMatch`.
pub plutus_v3_cost_model: Option<Vec<i64>>,
/// Conway DRep registration deposit (ledger param `drep_deposit`).
/// Mainnet default: 500 ADA. Used by `governance::build_signed_drep_*`.
/// Pass the chain's current value when constructing — registering
/// with the wrong amount fails ledger validation silently.
pub drep_deposit_lovelace: u64,
}
impl Default for ProtocolParams {
@ -95,6 +100,7 @@ impl Default for ProtocolParams {
// or fetched from `epoch_params`. None by default keeps
// the ada-only / mint paths zero-cost.
plutus_v3_cost_model: None,
drep_deposit_lovelace: 500_000_000,
}
}
}

View file

@ -2243,31 +2243,81 @@ impl WalletService {
.ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?;
let tip_ms = mainnet_slot_to_posix_ms(tip_slot)?;
// Compute the transition based on current status + tip time vs windows.
// Compute the transition from current status + tx-validity vs window
// boundaries. The validator (Proposal/Scripts.hs PAdvanceProposal)
// checks `getTimingRelation period`, which evaluates to:
// - PWithin if `period_start <= lb && ub <= period_end`
// - PAfter if `period_end < lb` (strict)
// - script-error otherwise (including the boundary-straddling case)
// So our tx validity range [tip_ms, tip_ms + 1799s] must fully sit
// either inside the period OR strictly after period_end. Any
// straddle = waste of fees.
//
// AUDIT-2026-05-06 H-1/H-2/H-4 fixes: use STRICT > on PAfter
// boundary, require tx-upper to land inside the target period for
// 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;
let tx_lower_ms = tip_ms;
let tx_upper_ms = tip_ms + VALIDITY_RANGE_MS;
let st = target.datum.starting_time;
let tc = &target.datum.timing_config;
let drafting_end = st + tc.draft_time;
let voting_end = drafting_end + tc.voting_time;
let locking_end = voting_end + tc.locking_time;
let executing_end = locking_end + tc.executing_time;
let transition = match target.datum.status {
PS::Draft => {
if tip_ms < drafting_end {
if tx_lower_ms >= st && tx_upper_ms <= drafting_end {
// Fully inside drafting period — happy path.
AdvanceTransition::DraftToVotingReady
} else {
} else if tx_lower_ms > drafting_end {
// Strictly after — failed-too-late path.
AdvanceTransition::DraftToFinished
} else {
return Err(format!(
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms straddles drafting \
period boundary [{st}, {drafting_end}]; wait ~{} ms for tx upper to clear, \
OR refuse to advance until status is past Draft",
drafting_end.saturating_sub(tx_lower_ms)
));
}
}
PS::VotingReady => {
// Window for V→L is [voting_end, locking_end]. After that → Finished.
if tip_ms < locking_end {
if tx_lower_ms >= voting_end && tx_upper_ms <= locking_end {
AdvanceTransition::VotingReadyToLocked
} else {
} else if tx_lower_ms > locking_end {
AdvanceTransition::VotingReadyToFinished
} else if tx_lower_ms < voting_end {
return Err(format!(
"tx validity range starts at {tx_lower_ms} ms, before voting_end \
{voting_end} ms voting window not yet closed; cannot advance to Locked"
));
} else {
return Err(format!(
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms straddles locking \
period boundary [{voting_end}, {locking_end}]; wait ~{} ms for tx upper \
to clear",
locking_end.saturating_sub(tx_lower_ms)
));
}
}
PS::Locked => {
if tx_lower_ms > executing_end {
AdvanceTransition::LockedToFinished
} else {
return Err(format!(
"tip too early to advance Locked→Finished without GAT mint — executing \
period ends at {executing_end} ms, currently {tx_lower_ms} ms (~{} ms \
remaining). The GAT-mint LockedFinished path (effected proposals) is \
Phase 4c-bis; for now the InfoOnly path requires the executing period \
to fully elapse first.",
executing_end.saturating_sub(tx_lower_ms)
));
}
}
PS::Locked => AdvanceTransition::LockedToFinished,
PS::Finished => {
return Err(format!(
"proposal #{} is already Finished — cannot advance further",
@ -2653,6 +2703,32 @@ impl WalletService {
let validity_upper_slot = tip_slot
+ aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS;
let validity_upper_ms = mainnet_slot_to_posix_ms(validity_upper_slot)?;
let tx_lower_ms = mainnet_slot_to_posix_ms(tip_slot)?;
// AUDIT-2026-05-06 H-3 fix: validator (Proposal/Scripts.hs PVote
// ~L511) demands `pgetRelation == PWithin VotingPeriod`, where
// PWithin requires BOTH `voting_start <= lb` AND `ub <= voting_end`.
// The builder's existing preflight only verified the upper bound;
// a vote-too-early call (tip < voting_start) would burn fees on a
// "too early or invalid" script error. Catch lb-vs-voting_start
// here too.
let voting_start_check = target.datum.starting_time
+ target.datum.timing_config.draft_time;
let voting_end_check = voting_start_check
+ target.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 \
(proposal #{proposal_id} draft period not over yet); wait ~{} ms",
voting_start_check.saturating_sub(tx_lower_ms)
));
}
if validity_upper_ms > voting_end_check {
return Err(format!(
"tx upper bound {validity_upper_ms} ms is after voting window end {voting_end_check} ms \
voting closed for proposal #{proposal_id}"
));
}
// Wallet utxos with H-5-style asset propagation.
let wallet_utxos: Vec<DaoWalletUtxo> = {