feat(dao): multi-net slot↔ms + live-decode StakeDatum tests

## Multi-net slot↔ms (drops mainnet-only gate)

Replaces mainnet_slot_to_posix_ms with slot_to_posix_ms(network, slot)
that handles all three Cardano networks. Vote + advance MCP tools no
longer hard-error on preprod/preview.

Per-network Shelley HF constants (slot, posix_ms_at_slot):
- mainnet:  4_492_800   1_596_059_091_000  (2020-07-29 21:44:51 UTC)
- preprod:  86_400      1_655_769_600_000  (2022-06-21 00:00 UTC)
- preview:  0           1_666_656_000_000  (2022-10-25 00:00 UTC)

Pre-Shelley (Byron) slots still rejected — they had different lengths
and DAO operations don't need them. Routed via cfg.network at every
call site so the MCP tool's behavior follows the active DAO's chain.

## Live-decode StakeDatum regression tests

Anchors the StakeDatum type port to two real on-chain UTxOs at the
Sulkta stakes_addr:

- decodes_sulkta_live_kayos_stake — 50 Terrapin, owner pkh 84d0..f2f3,
  utxo d5b73a9d...#0
- decodes_sulkta_live_cobb_stake  — 250 Terrapin, owner pkh c5e3..bfda,
  utxo 0823a940...#1

Both assert byte-exact CBOR round-trip after decode → to_plutus_data
→ encode. This is the validator-critical property: the proposal
validator does `mkRecordConstr expectedDatum #== outputDatum`
on every output, so any drift in field order, integer width, or
empty-list shape would silently break vote/cosign/advance txs.
Companion to decodes_sulkta_live_proposal_zero in proposal.rs (which
covers the 8-field ProposalDatum side).
This commit is contained in:
Kayos 2026-05-06 08:38:15 -07:00
parent 569c336f1d
commit 6a408c319a
2 changed files with 98 additions and 33 deletions

View file

@ -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 = [

View file

@ -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 slotms 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<i64, String> {
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<i64, String> {
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}); \
slotms 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