From a485a6f0bfa73b0468ed257c48ccc024437cbcef Mon Sep 17 00:00:00 2001 From: Kayos Date: Fri, 8 May 2026 11:09:26 -0700 Subject: [PATCH] fix(dao,mcp): clamp proposal_advance validity range to fit phase window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/builder/proposal_advance.rs | 37 ++++++++++++-- crates/aldabra-mcp/src/tools.rs | 51 +++++++++++++++++-- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/aldabra-dao/src/builder/proposal_advance.rs b/crates/aldabra-dao/src/builder/proposal_advance.rs index 44faf60..2b430ac 100644 --- a/crates/aldabra-dao/src/builder/proposal_advance.rs +++ b/crates/aldabra-dao/src/builder/proposal_advance.rs @@ -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, - /// 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, + /// 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, /// 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, } } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index cb61fdd..69cc65e 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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 = None; + let mut invalid_from_slot_override: Option = 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 Draft→Finished 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 \ + 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 { 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())?;