merge: vote TTL/posix_time consistency fix

Validates Track #30 vote bug. Working vote tx d9142849ac0f2525ab942a979c5662bf805a9e0c2184c4473f77e586ff3eeaee
on preprod_test2 prop #8, block 4690176. Three-day chase reduced to:
TX TTL slot ≠ slot underlying validity_upper_ms (which the MCP layer
clamped to voting_end). Validator's ppermitVote synthesizes the
expected Voted lock from chain's reconstructed validRange.upperBound
(derived from TTL slot); ours used the clamped value. Mismatch →
ponlyLocksUpdated assertion fails → silent UPLC error.

See audits/agora-vote-bug-2026-05-08/10-fix-validated.md for the
byte-level proof + comparison vs Clarity's working vote 4f2fac98....
This commit is contained in:
Kayos 2026-05-09 08:27:12 -07:00
commit 72bb8fa38e
2 changed files with 92 additions and 14 deletions

View file

@ -110,15 +110,23 @@ pub struct ProposalVoteArgs {
pub change_address: String,
/// Spendable wallet UTxOs.
pub wallet_utxos: Vec<WalletUtxo>,
/// 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(),

View file

@ -103,6 +103,48 @@ fn resolve_ref_script_bytes(
}
}
/// Resolve the Plutus minting-policy CBOR from EITHER an inline
/// hex argument OR a file path inside the container. Caller passes
/// both raw options; this fn enforces the "exactly one" rule and
/// reads the file when path is set.
///
/// Mirrors [`resolve_ref_script_bytes`] — same workaround for the
/// 2026-05-07 MCP transport bug where hex strings >~ 4500 chars
/// get a 1-byte truncation between Claude Code and aldabra's stdio
/// reader, surfacing as "odd length" hex decode errors and blocking
/// debug-build minting policies. Reading from a file inside the
/// container bypasses the JSON-RPC arg path entirely.
fn resolve_policy_cbor_bytes(
cbor_hex: Option<&str>,
path: Option<&str>,
) -> Result<Vec<u8>, String> {
match (cbor_hex, path) {
(Some(_), Some(_)) => Err(
"set at most one of policy_cbor_hex / policy_cbor_path".into(),
),
(Some(s), None) => {
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
hex_decode(&cleaned).map_err(|e| format!("decode policy_cbor_hex: {e}"))
}
(None, Some(p)) => {
let raw = std::fs::read_to_string(p)
.map_err(|e| format!("read policy_cbor_path '{p}': {e}"))?;
let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
if cleaned.is_empty() {
return Err(format!(
"policy_cbor_path '{p}' contained no hex characters"
));
}
hex_decode(&cleaned).map_err(|e| {
format!("decode policy_cbor_path '{p}' contents: {e}")
})
}
(None, None) => {
Err("must set exactly one of policy_cbor_hex / policy_cbor_path".into())
}
}
}
/// Parse a user-supplied script-kind string ("PlutusV1" / "PlutusV2"
/// / "PlutusV3" / "Native") into the pallas `ScriptKind` enum used
/// by the reference-script attachment helper. Case-insensitive,
@ -398,7 +440,22 @@ pub struct PolicyCreateArgs {
pub struct PlutusMintUnsignedArgs {
/// Plutus minting policy script CBOR (hex). 28-byte blake2b
/// hash with the version tag becomes the policy_id.
pub policy_cbor_hex: String,
/// At most one of `policy_cbor_hex` or `policy_cbor_path` may
/// be set; exactly one must be set.
#[serde(default)]
pub policy_cbor_hex: Option<String>,
/// Path INSIDE THE ALDABRA CONTAINER to a file containing
/// hex-encoded Plutus policy CBOR. Use INSTEAD of
/// `policy_cbor_hex` for scripts >~ 4500 chars to bypass the
/// MCP large-string transport bug (caught 2026-05-07: hex strings
/// > ~4500 chars get a 1-byte truncation + structural rearrangement
/// somewhere between Claude Code and aldabra's stdio reader,
/// surfacing as "odd length" hex decode errors). File contents
/// may include leading/trailing whitespace; only hex chars are
/// decoded. At most one of `policy_cbor_hex` or `policy_cbor_path`
/// may be set; exactly one must be set.
#[serde(default)]
pub policy_cbor_path: Option<String>,
/// Plutus version: "v1", "v2", or "v3".
pub policy_version: String,
/// PlutusData CBOR redeemer (hex) for the mint redeemer entry.
@ -1612,12 +1669,13 @@ impl WalletService {
#[tool(
name = "wallet_plutus_mint_unsigned",
description = "Build (no sign) a Plutus-policy mint with custom output. Distinct from wallet_mint (native script only). Args: policy_cbor_hex + policy_version (v1/v2/v3) + redeemer_cbor_hex + ex_units (mem+steps), mint_assets (array of {asset_name_hex, quantity}), dest_address + dest_lovelace + dest_extra_assets + dest_inline_datum_cbor_hex (req for script addrs), required_input_refs (array of 'txhash#index' UTxOs that MUST be spent — e.g. gstOutRef for parameterized policies). Returns JSON {cbor_hex, summary}. Pass through wallet_sign_partial → wallet_submit_signed_tx. Designed for Agora-style DAO bringup: governor bootstrap, stake bootstrap, proposal create."
description = "Build (no sign) a Plutus-policy mint with custom output. Distinct from wallet_mint (native script only). Args: policy_cbor_hex OR policy_cbor_path (use path for >~4500-char scripts to bypass the MCP large-string transport bug) + policy_version (v1/v2/v3) + redeemer_cbor_hex + ex_units (mem+steps), mint_assets (array of {asset_name_hex, quantity}), dest_address + dest_lovelace + dest_extra_assets + dest_inline_datum_cbor_hex (req for script addrs), required_input_refs (array of 'txhash#index' UTxOs that MUST be spent — e.g. gstOutRef for parameterized policies). Returns JSON {cbor_hex, summary}. Pass through wallet_sign_partial → wallet_submit_signed_tx. Designed for Agora-style DAO bringup: governor bootstrap, stake bootstrap, proposal create."
)]
async fn wallet_plutus_mint_unsigned(
&self,
#[tool(aggr)] PlutusMintUnsignedArgs {
policy_cbor_hex,
policy_cbor_path,
policy_version,
redeemer_cbor_hex,
mint_assets,
@ -1639,8 +1697,10 @@ impl WalletService {
));
}
let policy_cbor =
hex_decode(&policy_cbor_hex).map_err(|e| format!("decode policy_cbor: {e}"))?;
let policy_cbor = resolve_policy_cbor_bytes(
policy_cbor_hex.as_deref(),
policy_cbor_path.as_deref(),
)?;
let redeemer_cbor =
hex_decode(&redeemer_cbor_hex).map_err(|e| format!("decode redeemer: {e}"))?;
let policy_ver = match policy_version.trim().to_ascii_lowercase().as_str() {
@ -3214,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,