fix(dao,mcp): clamp proposal_advance validity range to fit phase window

The advance builder previously hard-coded the validity range to
[tip_slot, tip_slot + VALIDITY_RANGE_SLOTS=1799]. For early
Draft→VotingReady advance with the wide 1799-slot range, the upper
bound shoots ~30min past starting_time — way past drafting_end on
any DAO with windows narrower than 30 min, including Sulkta-shape
30-min DAOs whose drafting period happens to start partly elapsed.
Validator's getTimingRelation rejects straddles and the MCP layer
then errored 'tx validity range straddles drafting period
boundary'.

Two-part fix:

1. ProposalAdvanceArgs gains optional valid_from_slot_override +
   invalid_from_slot_override fields. None preserves legacy behavior;
   Some(...) lets the MCP layer dictate a clamped range. Builder
   defends against degenerate ranges (invalid_from <= valid_from).

2. MCP-side dao_proposal_advance_unsigned, when transition is
   DraftToVotingReady or VotingReadyToLocked and the natural [tip,
   tip+1799] would overshoot the period end, clamps invalid_from
   to the period_end slot. Refuses if remaining slots < 5 (chain
   has no room to include the tx) and prompts the caller to wait
   for the failed-too-late path instead.

Caught 2026-05-08 trying to drive preprod_test2 proposal #0
through the proper Draft→VotingReady arc — chain time was ~3 min
past starting_time, so 1799-slot range overshot drafting_end by
~27 min. Same code path now clamps to the remaining ~27 min of
drafting period.

Closes audit finding H-2 for the DraftToVotingReady and
VotingReadyToLocked transitions. Cosign + vote builders also have
H-2 (raw tip_slot for validity_from); those are deferred.
This commit is contained in:
Sulkta 2026-05-08 11:09:26 -07:00
parent 48679883ec
commit 892e4b0ae1
2 changed files with 80 additions and 8 deletions

View file

@ -2620,26 +2620,65 @@ impl WalletService {
let locking_end = voting_end + tc.locking_time;
let executing_end = locking_end + tc.executing_time;
// The validity-range overrides we'll pass to the builder. None
// = builder uses [tip_slot, tip_slot + VALIDITY_RANGE_SLOTS] as
// before. Some(...) lets us clamp when the natural range would
// straddle a phase boundary — e.g. early Draft→VotingReady
// advance with the wide 1799-slot range ends 30min past
// starting_time, way past drafting_end on a 30-min DAO.
let mut valid_from_slot_override: Option<u64> = None;
let mut invalid_from_slot_override: Option<u64> = None;
let transition = match target.datum.status {
PS::Draft => {
if tx_lower_ms >= st && tx_upper_ms <= drafting_end {
// Fully inside drafting period — happy path.
// Already fully inside drafting period — happy path.
AdvanceTransition::DraftToVotingReady
} else if tx_lower_ms >= st && tx_lower_ms < drafting_end {
// Lower is in drafting but upper overflows. Clamp
// upper to drafting_end so the range fits — still
// satisfies validator's PWithin check, just narrower.
// Min 5-slot width so the chain has room to include.
let drafting_end_slot = posix_ms_to_slot(cfg.network, drafting_end)?;
if drafting_end_slot <= tip_slot + 5 {
return Err(format!(
"Draft→VotingReady early-advance: only {} slots of drafting period \
remaining (drafting_end_slot={drafting_end_slot}, tip_slot={tip_slot}); \
too narrow to include the tx. Wait for drafting period to fully expire \
then re-call to take the DraftFinished path.",
drafting_end_slot.saturating_sub(tip_slot),
));
}
invalid_from_slot_override = Some(drafting_end_slot);
AdvanceTransition::DraftToVotingReady
} 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)
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms cannot reach a clean \
in-drafting window [{st}, {drafting_end}] nor a strictly-past-drafting \
window proposal starting_time may be in the future"
));
}
}
PS::VotingReady => {
if tx_lower_ms >= voting_end && tx_upper_ms <= locking_end {
AdvanceTransition::VotingReadyToLocked
} else if tx_lower_ms >= voting_end && tx_lower_ms < locking_end {
// Clamp upper to locking_end — same trick as Draft.
let locking_end_slot = posix_ms_to_slot(cfg.network, locking_end)?;
if locking_end_slot <= tip_slot + 5 {
return Err(format!(
"VotingReady→Locked: only {} slots of locking window remaining \
(locking_end_slot={locking_end_slot}, tip_slot={tip_slot}); \
too narrow to include the tx. Wait then re-call to take \
VotingReadyFinished.",
locking_end_slot.saturating_sub(tip_slot),
));
}
invalid_from_slot_override = Some(locking_end_slot);
AdvanceTransition::VotingReadyToLocked
} else if tx_lower_ms > locking_end {
AdvanceTransition::VotingReadyToFinished
} else if tx_lower_ms < voting_end {
@ -2750,6 +2789,8 @@ impl WalletService {
wallet_utxos,
advancer_pkh,
tip_slot,
valid_from_slot_override,
invalid_from_slot_override,
fee_lovelace,
})
.map_err(|e| e.to_string())?;