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:
parent
569c336f1d
commit
6a408c319a
2 changed files with 98 additions and 33 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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<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}); \
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue