fix(dao): anchor proposal-create validity range on starting_time_slot

Public Koios's tip endpoint can lag the actual chain by 100+ slots.
Under a 29-slot governor window that lag pushes invalid_after into
the past before the tx ever reaches a node — every retry hits
OutsideValidityIntervalUTxO no matter how fast we sign+submit.

Now: caller passes starting_time_slot derived from starting_time_ms
via the network's shelley constants; builder uses it as valid_from.
Caller can shift starting_time_ms slightly into the future to
compensate for MCP roundtrip latency. The on-chain
'pvalidateProposalStartingTime' is still satisfied because
starting_time_slot ∈ validRange by construction.
This commit is contained in:
Kayos 2026-05-07 17:31:45 -07:00
parent 66eacf5749
commit f44a4f209c
2 changed files with 39 additions and 6 deletions

View file

@ -187,10 +187,17 @@ pub struct ProposalCreateArgs {
/// validity range when converted to slots — caller handles the
/// slot↔ms conversion.
pub starting_time_ms: i64,
/// Current chain tip slot. AUDIT-C3 — sets `valid_from_slot(tip_slot)`
/// and `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`. The
/// resulting window must satisfy
/// `governor.create_proposal_time_range_max_width` (Sulkta: 30min).
/// Slot to anchor `valid_from_slot(...)` on. Derived by the caller
/// from `starting_time_ms` via the network's shelley constants.
/// Anchoring on this (rather than a freshly-fetched koios tip) lets
/// the caller compensate for Koios tip-lag by setting the
/// starting_time slightly into the future. Validator's
/// `pvalidateProposalStartingTime` sees `starting_time_slot ∈
/// validRange` by construction.
pub starting_time_slot: u64,
/// Current chain tip slot. Retained for caller-side fee/sanity
/// math; no longer drives the validity range as of 2026-05-07
/// (see `starting_time_slot` above).
pub tip_slot: u64,
/// Reference UTxO to cite for the governor validator script.
pub governor_validator_ref: ReferenceUtxo,
@ -582,8 +589,19 @@ pub fn build_unsigned_proposal_create(
let max_width_slots = ((args.governor.datum.create_proposal_time_range_max_width / 1_000) as u64)
.saturating_sub(1)
.min(VALIDITY_RANGE_SLOTS);
staging = staging.valid_from_slot(args.tip_slot);
staging = staging.invalid_from_slot(args.tip_slot + max_width_slots);
// 2026-05-07: anchor the validity range to caller-supplied
// `starting_time_slot` instead of `tip_slot`. Public Koios's tip
// endpoint can lag the actual chain by 100+ slots; under a 29s
// governor window that lag pushes invalid_after into the past
// before the tx ever reaches a node. Caller passes a slightly-future
// starting_time_slot (e.g. tip+30); the on-chain
// `OutsideValidityIntervalUTxO` check then has a window that
// straddles when the tx actually lands, while the in-script
// `pvalidateProposalStartingTime` is satisfied because
// `starting_time_slot ∈ [valid_from, invalid_after - 1]` by
// construction.
staging = staging.valid_from_slot(args.starting_time_slot);
staging = staging.invalid_from_slot(args.starting_time_slot + max_width_slots);
let proposer_pkh_arr: [u8; 28] = args
.proposer_pkh
@ -752,6 +770,7 @@ mod tests {
},
},
tip_slot: 180_062_536,
starting_time_slot: 180_062_536,
stake_validator_ref: ReferenceUtxo {
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
output_index: 2,

View file

@ -2390,6 +2390,7 @@ impl WalletService {
change_address: self.inner.address.clone(),
wallet_utxos,
starting_time_ms,
starting_time_slot: posix_ms_to_slot(cfg.network, starting_time_ms)?,
tip_slot,
governor_validator_ref,
stake_validator_ref,
@ -3366,6 +3367,19 @@ fn shelley_constants(network: DaoNetwork) -> (u64, i64) {
}
}
/// Convert POSIX milliseconds to an absolute slot for the given network.
fn posix_ms_to_slot(network: DaoNetwork, posix_ms: i64) -> Result<u64, String> {
let (slot_zero, posix_ms_zero) = shelley_constants(network);
if posix_ms < posix_ms_zero {
return Err(format!(
"posix_ms {posix_ms} is pre-Shelley on {network:?} (< {posix_ms_zero})"
));
}
let delta_ms = posix_ms - posix_ms_zero;
let delta_slots = (delta_ms / 1000) as u64;
Ok(slot_zero + delta_slots)
}
/// Convert an absolute slot to POSIX milliseconds for the given network.
///
/// Caveat: only valid for slots ≥ that network's Shelley-HF slot. Returns