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

@ -154,8 +154,21 @@ pub struct ProposalAdvanceArgs {
/// Wallet's payment-credential hash (28 bytes) — needed for the
/// disclosed_signer; the funding utxo's vkey witness will sign.
pub advancer_pkh: Vec<u8>,
/// Current chain tip slot.
/// Current chain tip slot. Used as the floor for `valid_from_slot`
/// when no caller override is supplied.
pub tip_slot: u64,
/// Optional explicit `valid_from_slot` override. When `None`, the
/// builder uses `tip_slot`. Set this when the MCP layer (or any
/// other caller) needs to clamp the range to fit inside a phase
/// window — e.g. early Draft→VotingReady advance whose validity
/// range must sit fully inside the drafting period rather than
/// straddling drafting_end.
pub valid_from_slot_override: Option<u64>,
/// Optional explicit `invalid_from_slot` override (= validity
/// upper-bound, exclusive). When `None`, the builder uses
/// `tip_slot + VALIDITY_RANGE_SLOTS`. Pair with
/// `valid_from_slot_override` for phase-clamped advances.
pub invalid_from_slot_override: Option<u64>,
/// Estimated total fee.
pub fee_lovelace: u64,
}
@ -422,8 +435,24 @@ pub fn build_unsigned_proposal_advance(
Some(ADVANCE_SPEND_EX_UNITS),
);
staging = staging.valid_from_slot(args.tip_slot);
staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS);
// Validity range. Honor caller-supplied overrides if set — that's
// how the MCP layer clamps the range to fit inside a phase window
// (e.g. early Draft→VotingReady advance must sit fully inside the
// drafting period). Default behavior (None overrides) keeps the
// legacy `[tip, tip + 1799]` range that's right for Sulkta-shape
// 30-min windows but fails on tighter/expired windows.
let valid_from = args.valid_from_slot_override.unwrap_or(args.tip_slot);
let invalid_from = args
.invalid_from_slot_override
.unwrap_or(args.tip_slot + VALIDITY_RANGE_SLOTS);
if invalid_from <= valid_from {
return Err(DaoError::State(format!(
"validity range degenerate: valid_from={valid_from}, invalid_from={invalid_from}; \
override values must satisfy invalid_from > valid_from"
)));
}
staging = staging.valid_from_slot(valid_from);
staging = staging.invalid_from_slot(invalid_from);
let advancer_pkh_arr: [u8; 28] = args
.advancer_pkh
@ -587,6 +616,8 @@ mod tests {
],
advancer_pkh: advancer_pkh(),
tip_slot: 180_062_536,
valid_from_slot_override: None,
invalid_from_slot_override: None,
fee_lovelace: 2_500_000,
}
}