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:
parent
48679883ec
commit
892e4b0ae1
2 changed files with 80 additions and 8 deletions
|
|
@ -154,8 +154,21 @@ pub struct ProposalAdvanceArgs {
|
||||||
/// Wallet's payment-credential hash (28 bytes) — needed for the
|
/// Wallet's payment-credential hash (28 bytes) — needed for the
|
||||||
/// disclosed_signer; the funding utxo's vkey witness will sign.
|
/// disclosed_signer; the funding utxo's vkey witness will sign.
|
||||||
pub advancer_pkh: Vec<u8>,
|
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,
|
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.
|
/// Estimated total fee.
|
||||||
pub fee_lovelace: u64,
|
pub fee_lovelace: u64,
|
||||||
}
|
}
|
||||||
|
|
@ -422,8 +435,24 @@ pub fn build_unsigned_proposal_advance(
|
||||||
Some(ADVANCE_SPEND_EX_UNITS),
|
Some(ADVANCE_SPEND_EX_UNITS),
|
||||||
);
|
);
|
||||||
|
|
||||||
staging = staging.valid_from_slot(args.tip_slot);
|
// Validity range. Honor caller-supplied overrides if set — that's
|
||||||
staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS);
|
// 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
|
let advancer_pkh_arr: [u8; 28] = args
|
||||||
.advancer_pkh
|
.advancer_pkh
|
||||||
|
|
@ -587,6 +616,8 @@ mod tests {
|
||||||
],
|
],
|
||||||
advancer_pkh: advancer_pkh(),
|
advancer_pkh: advancer_pkh(),
|
||||||
tip_slot: 180_062_536,
|
tip_slot: 180_062_536,
|
||||||
|
valid_from_slot_override: None,
|
||||||
|
invalid_from_slot_override: None,
|
||||||
fee_lovelace: 2_500_000,
|
fee_lovelace: 2_500_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2620,26 +2620,65 @@ impl WalletService {
|
||||||
let locking_end = voting_end + tc.locking_time;
|
let locking_end = voting_end + tc.locking_time;
|
||||||
let executing_end = locking_end + tc.executing_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 {
|
let transition = match target.datum.status {
|
||||||
PS::Draft => {
|
PS::Draft => {
|
||||||
if tx_lower_ms >= st && tx_upper_ms <= drafting_end {
|
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 Draft→Finished path.",
|
||||||
|
drafting_end_slot.saturating_sub(tip_slot),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
invalid_from_slot_override = Some(drafting_end_slot);
|
||||||
AdvanceTransition::DraftToVotingReady
|
AdvanceTransition::DraftToVotingReady
|
||||||
} else if tx_lower_ms > drafting_end {
|
} else if tx_lower_ms > drafting_end {
|
||||||
// Strictly after — failed-too-late path.
|
// Strictly after — failed-too-late path.
|
||||||
AdvanceTransition::DraftToFinished
|
AdvanceTransition::DraftToFinished
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms straddles drafting \
|
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms cannot reach a clean \
|
||||||
period boundary [{st}, {drafting_end}]; wait ~{} ms for tx upper to clear, \
|
in-drafting window [{st}, {drafting_end}] nor a strictly-past-drafting \
|
||||||
OR refuse to advance until status is past Draft",
|
window — proposal starting_time may be in the future"
|
||||||
drafting_end.saturating_sub(tx_lower_ms)
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PS::VotingReady => {
|
PS::VotingReady => {
|
||||||
if tx_lower_ms >= voting_end && tx_upper_ms <= locking_end {
|
if tx_lower_ms >= voting_end && tx_upper_ms <= locking_end {
|
||||||
AdvanceTransition::VotingReadyToLocked
|
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 \
|
||||||
|
VotingReady→Finished.",
|
||||||
|
locking_end_slot.saturating_sub(tip_slot),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
invalid_from_slot_override = Some(locking_end_slot);
|
||||||
|
AdvanceTransition::VotingReadyToLocked
|
||||||
} else if tx_lower_ms > locking_end {
|
} else if tx_lower_ms > locking_end {
|
||||||
AdvanceTransition::VotingReadyToFinished
|
AdvanceTransition::VotingReadyToFinished
|
||||||
} else if tx_lower_ms < voting_end {
|
} else if tx_lower_ms < voting_end {
|
||||||
|
|
@ -2750,6 +2789,8 @@ impl WalletService {
|
||||||
wallet_utxos,
|
wallet_utxos,
|
||||||
advancer_pkh,
|
advancer_pkh,
|
||||||
tip_slot,
|
tip_slot,
|
||||||
|
valid_from_slot_override,
|
||||||
|
invalid_from_slot_override,
|
||||||
fee_lovelace,
|
fee_lovelace,
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue