From f44a4f209cb15c004c8a70625bd9905f0052268a Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 7 May 2026 17:31:45 -0700 Subject: [PATCH] fix(dao): anchor proposal-create validity range on starting_time_slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/builder/proposal_create.rs | 31 +++++++++++++++---- crates/aldabra-mcp/src/tools.rs | 14 +++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/aldabra-dao/src/builder/proposal_create.rs b/crates/aldabra-dao/src/builder/proposal_create.rs index 4af700b..ef67c07 100644 --- a/crates/aldabra-dao/src/builder/proposal_create.rs +++ b/crates/aldabra-dao/src/builder/proposal_create.rs @@ -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, diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 4fd1800..9e87003 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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 { + 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