diff --git a/crates/aldabra-dao/src/agora/stake.rs b/crates/aldabra-dao/src/agora/stake.rs index 7925201..3a693ee 100644 --- a/crates/aldabra-dao/src/agora/stake.rs +++ b/crates/aldabra-dao/src/agora/stake.rs @@ -341,6 +341,64 @@ mod tests { assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s); } + /// Decode Kayos's actual on-chain stake from Sulkta's stakes_addr. + /// Anchors the StakeDatum type port to a real UTxO so a future + /// encoding refactor can't silently break decode of existing stakes. + /// + /// Source: Koios `address_info` for stakes addr + /// `addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8`, + /// utxo `d5b73a9d1e0fc4cedaf25b1172d379ad36bc39ec8516005cd70b12f9b5bdaa2f#0`. + /// Captured 2026-05-06. + #[test] + fn decodes_sulkta_live_kayos_stake() { + use pallas_primitives::PlutusData; + let cbor_hex = "9f1832d8799f581c84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3ffd87a8080ff"; + let bytes = hex::decode(cbor_hex).unwrap(); + let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap(); + let stake = StakeDatum::from_plutus_data(&pd).expect("decode Kayos stake"); + assert_eq!(stake.staked_amount, 50); + assert!(matches!( + &stake.owner, + Credential::PubKey(h) if hex::encode(h) == "84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3" + )); + assert!(stake.delegated_to.is_none()); + assert!(stake.locked_by.is_empty()); + + // Round-trip — encoding our decoded stake should give back the + // exact bytes we started with. This is the critical property: + // any drift in field order, integer encoding, or empty-list + // shape would break the validator's bit-exact `==` check on + // mutated stake outputs. + let re_encoded = pallas_codec::minicbor::to_vec(&stake.to_plutus_data().unwrap()).unwrap(); + assert_eq!( + hex::encode(&re_encoded), + cbor_hex, + "round-trip CBOR diverged" + ); + } + + /// Same shape as `decodes_sulkta_live_kayos_stake` but for Cobb's + /// stake (250 Terrapin). Two-witness regression — catches drift + /// even if the Kayos test happens to flatten over a bug. + #[test] + fn decodes_sulkta_live_cobb_stake() { + use pallas_primitives::PlutusData; + let cbor_hex = "9f18fad8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffd87a8080ff"; + let bytes = hex::decode(cbor_hex).unwrap(); + let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap(); + let stake = StakeDatum::from_plutus_data(&pd).expect("decode Cobb stake"); + assert_eq!(stake.staked_amount, 250); + assert!(matches!( + &stake.owner, + Credential::PubKey(h) if hex::encode(h) == "c5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfda" + )); + assert!(stake.delegated_to.is_none()); + assert!(stake.locked_by.is_empty()); + + let re_encoded = pallas_codec::minicbor::to_vec(&stake.to_plutus_data().unwrap()).unwrap(); + assert_eq!(hex::encode(&re_encoded), cbor_hex, "round-trip CBOR diverged"); + } + #[test] fn stake_redeemer_indices_match_make_is_data_indexed() { let cases = [ diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index a6ac1ec..e5a470f 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -2199,14 +2199,6 @@ impl WalletService { .resolve(dao.as_deref()) .map_err(|e| e.to_string())?; - if !matches!(cfg.network, DaoNetwork::Mainnet) { - return Err(format!( - "dao_proposal_advance_unsigned only supports mainnet for v1 \ - (current dao network: {:?})", - cfg.network - )); - } - // Find the proposal. let proposals = self .inner @@ -2241,7 +2233,7 @@ impl WalletService { .and_then(|t| t.get("abs_slot")) .and_then(|s| s.as_u64()) .ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?; - let tip_ms = mainnet_slot_to_posix_ms(tip_slot)?; + let tip_ms = slot_to_posix_ms(cfg.network, tip_slot)?; // Compute the transition from current status + tx-validity vs window // boundaries. The validator (Proposal/Scripts.hs PAdvanceProposal) @@ -2601,16 +2593,6 @@ impl WalletService { .resolve(dao.as_deref()) .map_err(|e| e.to_string())?; - // Network gate: slot↔ms conversion is mainnet-only for v1. - if !matches!(cfg.network, DaoNetwork::Mainnet) { - return Err(format!( - "dao_proposal_vote_unsigned only supports mainnet for v1 \ - (current dao network: {:?}); preprod/preview slot↔ms conversion \ - needs the network's Shelley genesis constants — TODO Phase 5", - cfg.network - )); - } - let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| { "DaoConfig.proposal_addr missing — register or discover_scripts first".to_string() })?; @@ -2702,8 +2684,8 @@ impl WalletService { .ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?; let validity_upper_slot = tip_slot + aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS; - let validity_upper_ms = mainnet_slot_to_posix_ms(validity_upper_slot)?; - let tx_lower_ms = mainnet_slot_to_posix_ms(tip_slot)?; + let validity_upper_ms = slot_to_posix_ms(cfg.network, validity_upper_slot)?; + let tx_lower_ms = slot_to_posix_ms(cfg.network, tip_slot)?; // AUDIT-2026-05-06 H-3 fix: validator (Proposal/Scripts.hs PVote // ~L511) demands `pgetRelation == PWithin VotingPeriod`, where @@ -3012,33 +2994,58 @@ pub struct DaoProposalVoteArgs { /// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion. /// -/// On Cardano mainnet, slot 4_492_800 corresponds to 2020-07-29 21:44:51 UTC -/// (POSIX 1_596_059_091 seconds), and Shelley+ era slots are 1 second wide. -/// Source: Cardano genesis files. +/// Per-network Shelley genesis constants for slot↔POSIX-ms conversion. +/// +/// Each tuple: (shelley_start_slot, shelley_start_posix_ms). Shelley+ era +/// uses 1-second slots on every network; the only network-specific values +/// are the start point of that 1-second-slot regime. +/// +/// **Mainnet** — Shelley HF at slot 4_492_800 (epoch 208), 2020-07-29 21:44:51 UTC. +/// Pre-Shelley (Byron) slots had a different length; we don't support them. +/// +/// **Preprod** — Byron-era genesis 2022-06-01, Shelley HF at slot 86_400 +/// (= 86_400 × 20s Byron slots = 20 days), posix 2022-06-21 00:00 UTC. +/// +/// **Preview** — single-era network; Shelley starts at slot 0, +/// posix 2022-10-25 00:00 UTC. (No Byron prologue.) const MAINNET_SHELLEY_SLOT_ZERO: u64 = 4_492_800; const MAINNET_SHELLEY_POSIX_MS_ZERO: i64 = 1_596_059_091_000; +const PREPROD_SHELLEY_SLOT_ZERO: u64 = 86_400; +const PREPROD_SHELLEY_POSIX_MS_ZERO: i64 = 1_655_769_600_000; +const PREVIEW_SHELLEY_SLOT_ZERO: u64 = 0; +const PREVIEW_SHELLEY_POSIX_MS_ZERO: i64 = 1_666_656_000_000; -/// Convert an absolute mainnet slot to POSIX milliseconds. +fn shelley_constants(network: DaoNetwork) -> (u64, i64) { + match network { + DaoNetwork::Mainnet => (MAINNET_SHELLEY_SLOT_ZERO, MAINNET_SHELLEY_POSIX_MS_ZERO), + DaoNetwork::Preprod => (PREPROD_SHELLEY_SLOT_ZERO, PREPROD_SHELLEY_POSIX_MS_ZERO), + DaoNetwork::Preview => (PREVIEW_SHELLEY_SLOT_ZERO, PREVIEW_SHELLEY_POSIX_MS_ZERO), + } +} + +/// Convert an absolute slot to POSIX milliseconds for the given network. /// -/// Caveat: only valid for slots ≥ `MAINNET_SHELLEY_SLOT_ZERO`. Returns -/// `Err` if slot is in the Byron era (pre-4_492_800) since slot lengths -/// differed there. We never need pre-Shelley slots for DAO operations. -fn mainnet_slot_to_posix_ms(slot: u64) -> Result { - if slot < MAINNET_SHELLEY_SLOT_ZERO { +/// Caveat: only valid for slots ≥ that network's Shelley-HF slot. Returns +/// `Err` for pre-Shelley (Byron) slots — they had a different length and +/// we never need them for DAO operations. +fn slot_to_posix_ms(network: DaoNetwork, slot: u64) -> Result { + let (slot_zero, posix_ms_zero) = shelley_constants(network); + if slot < slot_zero { return Err(format!( - "slot {slot} is pre-Shelley (< {MAINNET_SHELLEY_SLOT_ZERO}); \ + "slot {slot} is pre-Shelley on {network:?} (< {slot_zero}); \ slot↔ms conversion only supported for Shelley+ era" )); } - let delta_slots = slot - MAINNET_SHELLEY_SLOT_ZERO; + let delta_slots = slot - slot_zero; let delta_ms = (delta_slots as i64).checked_mul(1000).ok_or_else(|| { format!("slot delta {delta_slots} * 1000 overflows i64") })?; - MAINNET_SHELLEY_POSIX_MS_ZERO + posix_ms_zero .checked_add(delta_ms) .ok_or_else(|| "posix_ms add overflow".into()) } + /// Pull wallet UTxOs with H-5 strict asset-key parsing. /// /// Shared by every DAO write-path tool that needs to fund + collateralize