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:
Sulkta 2026-05-06 08:06:44 -07:00
parent 86a2787b7c
commit 8b48e7224e
4 changed files with 110 additions and 26 deletions

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> = {