fix(dao,mcp): tie vote tx TTL to validity_upper_slot so Voted.posix_time matches chain's reconstructed validRange.upperBound

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.
This commit is contained in:
Kayos 2026-05-09 06:09:48 -07:00
parent e679874939
commit 90883b50ce
2 changed files with 28 additions and 10 deletions

View file

@ -110,15 +110,23 @@ pub struct ProposalVoteArgs {
pub change_address: String, pub change_address: String,
/// Spendable wallet UTxOs. /// Spendable wallet UTxOs.
pub wallet_utxos: Vec<WalletUtxo>, pub wallet_utxos: Vec<WalletUtxo>,
/// Current chain tip slot. Sets `valid_from_slot(tip_slot)` and /// Current chain tip slot. Sets `valid_from_slot(tip_slot)`.
/// `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`.
pub tip_slot: u64, pub tip_slot: u64,
/// POSIX-ms equivalent of the validity range's UPPER bound (i.e. the /// Tx upper-bound slot. Sets `invalid_from_slot(validity_upper_slot)`.
/// slot `tip_slot + VALIDITY_RANGE_SLOTS` converted to ms via the /// Caller may clamp this to (e.g.) the proposal's voting_end_slot to
/// Shelley genesis epoch). Embedded as `Voted.posix_time` on the new /// keep the validity range inside the voting window. MUST be consistent
/// stake lock — must match what the validator extracts from /// with `validity_upper_ms` — both should encode the SAME slot via the
/// `PFullyBoundedTimeRange _ upperBound`. Caller is responsible for /// network's slot↔ms conversion. The chain's V2 ScriptContext computes
/// the slot↔ms conversion. /// `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, pub validity_upper_ms: i64,
/// Reference UTxO citing the stake validator script. /// Reference UTxO citing the stake validator script.
pub stake_validator_ref: ReferenceUtxo, pub stake_validator_ref: ReferenceUtxo,
@ -481,9 +489,13 @@ pub fn build_unsigned_proposal_vote(
); );
// Validity range — must be inside voting window (already preflighted) // 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.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 // Disclosed signer: voter pkh. The validator's `pisSignedBy` checks
// this against `txInfoSignatories`. // this against `txInfoSignatories`.
@ -644,6 +656,11 @@ mod tests {
}, },
], ],
tip_slot: 180_062_536, 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, validity_upper_ms,
stake_validator_ref: ReferenceUtxo { stake_validator_ref: ReferenceUtxo {
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(), tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),

View file

@ -3274,6 +3274,7 @@ impl WalletService {
change_address: self.inner.address.clone(), change_address: self.inner.address.clone(),
wallet_utxos, wallet_utxos,
tip_slot, tip_slot,
validity_upper_slot,
validity_upper_ms, validity_upper_ms,
stake_validator_ref, stake_validator_ref,
proposal_validator_ref, proposal_validator_ref,