From 90883b50ced6b9745dfc819a08b5ef2b860c7544 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 9 May 2026 06:09:48 -0700 Subject: [PATCH] fix(dao,mcp): tie vote tx TTL to validity_upper_slot so Voted.posix_time matches chain's reconstructed validRange.upperBound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this fix, proposal_vote.rs:486 set the tx TTL to `tip_slot + VALIDITY_RANGE_SLOTS` (the unclamped default) while the new stake output's Voted lock embedded `posix_time = validity_upper_ms` which the MCP layer at tools.rs:3197 may have CLAMPED to `voting_end_slot` to keep the validity range inside the voting window. The TX TTL slot and the slot underlying validity_upper_ms then diverged whenever the clamp fired. The chain reconstructs txInfo.validRange.upperBound from the TX TTL. Agora's ppermitVote synthesizes the expected Voted lock with `posix_time = upperBound` and ponlyLocksUpdated compares it to our output's lock. With a slot mismatch the lock posix_time differs by (default - voting_end) seconds — for a 1799-slot default and a small voting window remainder, this is hundreds-to-thousands of seconds. The mismatch surfaces as a silent UPLC error in the stake validator with no preceding ptrace, exactly matching the 'validator crashed / exited prematurely' chain rejections we've been chasing for two days. Verified hypothesis against working Clarity vote 4f2fac985a08db2349ef2a650bb66ca6cd42fab1ecc5976bb673687666922503: TTL slot 130276129 → posix_ms 1721842420000, Voted.posix_time 1721842420000 — exact match (diff 0 ms). Our failing prop #5 vote 4f2fac98... had TTL.posix_ms = 1778296041000 vs Voted.posix_time 1778294743000 = 1298s mismatch. Fix: introduce explicit `validity_upper_slot` field on ProposalVoteArgs alongside `validity_upper_ms`. Caller sets BOTH from the same source (MCP layer already had this slot in scope at the clamp site). The builder's TTL now uses validity_upper_slot (so the chain computes the same upperBound as our datum embedded). Other builders (cosign / advance / retract_votes) don't write a datum field that depends on slot↔ms conversion, so they're not affected by this bug. Test fixture updated to derive validity_upper_slot from validity_upper_ms via the mainnet shelley-zero constants. --- .../aldabra-dao/src/builder/proposal_vote.rs | 37 ++++++++++++++----- crates/aldabra-mcp/src/tools.rs | 1 + 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/aldabra-dao/src/builder/proposal_vote.rs b/crates/aldabra-dao/src/builder/proposal_vote.rs index 739e1c9..497ab2a 100644 --- a/crates/aldabra-dao/src/builder/proposal_vote.rs +++ b/crates/aldabra-dao/src/builder/proposal_vote.rs @@ -110,15 +110,23 @@ pub struct ProposalVoteArgs { pub change_address: String, /// Spendable wallet UTxOs. pub wallet_utxos: Vec, - /// Current chain tip slot. Sets `valid_from_slot(tip_slot)` and - /// `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`. + /// Current chain tip slot. Sets `valid_from_slot(tip_slot)`. pub tip_slot: u64, - /// POSIX-ms equivalent of the validity range's UPPER bound (i.e. the - /// slot `tip_slot + VALIDITY_RANGE_SLOTS` converted to ms via the - /// Shelley genesis epoch). Embedded as `Voted.posix_time` on the new - /// stake lock — must match what the validator extracts from - /// `PFullyBoundedTimeRange _ upperBound`. Caller is responsible for - /// the slot↔ms conversion. + /// Tx upper-bound slot. Sets `invalid_from_slot(validity_upper_slot)`. + /// Caller may clamp this to (e.g.) the proposal's voting_end_slot to + /// keep the validity range inside the voting window. MUST be consistent + /// with `validity_upper_ms` — both should encode the SAME slot via the + /// network's slot↔ms conversion. The chain's V2 ScriptContext computes + /// `txInfo.validRange.upperBound` from this slot, and the validator's + /// `ppermitVote` synthesizes the expected `Voted.posix_time` from + /// THAT upper bound. If the slot underlying `validity_upper_ms` differs + /// from this slot, the validator's `passert "Correct outputs"` fails. + pub validity_upper_slot: u64, + /// POSIX-ms equivalent of `validity_upper_slot`. Embedded as + /// `Voted.posix_time` on the new stake lock — must match what the + /// validator extracts from `PFullyBoundedTimeRange _ upperBound`. + /// Caller is responsible for the slot↔ms conversion AND for ensuring + /// `slot_to_posix_ms(validity_upper_slot) == validity_upper_ms`. pub validity_upper_ms: i64, /// Reference UTxO citing the stake validator script. pub stake_validator_ref: ReferenceUtxo, @@ -481,9 +489,13 @@ pub fn build_unsigned_proposal_vote( ); // Validity range — must be inside voting window (already preflighted) - // AND its width must be ≤ votingTimeRangeMaxWidth. + // AND its width must be ≤ votingTimeRangeMaxWidth. The TTL uses + // `validity_upper_slot` (which the caller may have clamped) so the + // chain's reconstructed `txInfo.validRange.upperBound` matches the + // `validity_upper_ms` we embedded in the new `Voted` lock — otherwise + // `ppermitVote`'s `passert "Correct outputs"` would crash silently. staging = staging.valid_from_slot(args.tip_slot); - staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS); + staging = staging.invalid_from_slot(args.validity_upper_slot); // Disclosed signer: voter pkh. The validator's `pisSignedBy` checks // this against `txInfoSignatories`. @@ -644,6 +656,11 @@ mod tests { }, ], tip_slot: 180_062_536, + // Derive from validity_upper_ms via mainnet shelley_zero. + // shelley_zero = (4_492_800, 1_596_059_091_000). + // slot = 4_492_800 + (validity_upper_ms - 1_596_059_091_000) / 1000. + validity_upper_slot: 4_492_800 + + ((validity_upper_ms - 1_596_059_091_000) / 1000) as u64, validity_upper_ms, stake_validator_ref: ReferenceUtxo { tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(), diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 7e41ad3..11fdd8e 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -3274,6 +3274,7 @@ impl WalletService { change_address: self.inner.address.clone(), wallet_utxos, tip_slot, + validity_upper_slot, validity_upper_ms, stake_validator_ref, proposal_validator_ref,