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_sulkta_stake — 50 Terrapin, owner pkh 84d0..f2f3, utxo d5b73a9d...#0 - decodes_sulkta_live_sulkta_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
783f4e5b0e
commit
6f935b58b2
2 changed files with 98 additions and 33 deletions
|
|
@ -341,6 +341,64 @@ mod tests {
|
||||||
assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s);
|
assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode an actual on-chain stake from the live 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_live_stake_datum() {
|
||||||
|
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 first 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_live_stake_datum` but for a second
|
||||||
|
/// stake (250 Terrapin). Two-witness regression — catches drift
|
||||||
|
/// even if the first test happens to flatten over a bug.
|
||||||
|
#[test]
|
||||||
|
fn decodes_live_stake_datum_b() {
|
||||||
|
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 second 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]
|
#[test]
|
||||||
fn stake_redeemer_indices_match_make_is_data_indexed() {
|
fn stake_redeemer_indices_match_make_is_data_indexed() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|
|
||||||
|
|
@ -2199,14 +2199,6 @@ impl WalletService {
|
||||||
.resolve(dao.as_deref())
|
.resolve(dao.as_deref())
|
||||||
.map_err(|e| e.to_string())?;
|
.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.
|
// Find the proposal.
|
||||||
let proposals = self
|
let proposals = self
|
||||||
.inner
|
.inner
|
||||||
|
|
@ -2241,7 +2233,7 @@ impl WalletService {
|
||||||
.and_then(|t| t.get("abs_slot"))
|
.and_then(|t| t.get("abs_slot"))
|
||||||
.and_then(|s| s.as_u64())
|
.and_then(|s| s.as_u64())
|
||||||
.ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?;
|
.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
|
// Compute the transition from current status + tx-validity vs window
|
||||||
// boundaries. The validator (Proposal/Scripts.hs PAdvanceProposal)
|
// boundaries. The validator (Proposal/Scripts.hs PAdvanceProposal)
|
||||||
|
|
@ -2601,16 +2593,6 @@ impl WalletService {
|
||||||
.resolve(dao.as_deref())
|
.resolve(dao.as_deref())
|
||||||
.map_err(|e| e.to_string())?;
|
.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(|| {
|
let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||||
"DaoConfig.proposal_addr missing — register or discover_scripts first".to_string()
|
"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}"))?;
|
.ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?;
|
||||||
let validity_upper_slot = tip_slot
|
let validity_upper_slot = tip_slot
|
||||||
+ aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS;
|
+ aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS;
|
||||||
let validity_upper_ms = mainnet_slot_to_posix_ms(validity_upper_slot)?;
|
let validity_upper_ms = slot_to_posix_ms(cfg.network, validity_upper_slot)?;
|
||||||
let tx_lower_ms = mainnet_slot_to_posix_ms(tip_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
|
// AUDIT-2026-05-06 H-3 fix: validator (Proposal/Scripts.hs PVote
|
||||||
// ~L511) demands `pgetRelation == PWithin VotingPeriod`, where
|
// ~L511) demands `pgetRelation == PWithin VotingPeriod`, where
|
||||||
|
|
@ -3012,33 +2994,58 @@ pub struct DaoProposalVoteArgs {
|
||||||
|
|
||||||
/// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion.
|
/// 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
|
/// Per-network Shelley genesis constants for slot↔POSIX-ms conversion.
|
||||||
/// (POSIX 1_596_059_091 seconds), and Shelley+ era slots are 1 second wide.
|
///
|
||||||
/// Source: Cardano genesis files.
|
/// 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_SLOT_ZERO: u64 = 4_492_800;
|
||||||
const MAINNET_SHELLEY_POSIX_MS_ZERO: i64 = 1_596_059_091_000;
|
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
|
/// Caveat: only valid for slots ≥ that network's Shelley-HF slot. Returns
|
||||||
/// `Err` if slot is in the Byron era (pre-4_492_800) since slot lengths
|
/// `Err` for pre-Shelley (Byron) slots — they had a different length and
|
||||||
/// differed there. We never need pre-Shelley slots for DAO operations.
|
/// we never need them for DAO operations.
|
||||||
fn mainnet_slot_to_posix_ms(slot: u64) -> Result<i64, String> {
|
fn slot_to_posix_ms(network: DaoNetwork, slot: u64) -> Result<i64, String> {
|
||||||
if slot < MAINNET_SHELLEY_SLOT_ZERO {
|
let (slot_zero, posix_ms_zero) = shelley_constants(network);
|
||||||
|
if slot < slot_zero {
|
||||||
return Err(format!(
|
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"
|
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(|| {
|
let delta_ms = (delta_slots as i64).checked_mul(1000).ok_or_else(|| {
|
||||||
format!("slot delta {delta_slots} * 1000 overflows i64")
|
format!("slot delta {delta_slots} * 1000 overflows i64")
|
||||||
})?;
|
})?;
|
||||||
MAINNET_SHELLEY_POSIX_MS_ZERO
|
posix_ms_zero
|
||||||
.checked_add(delta_ms)
|
.checked_add(delta_ms)
|
||||||
.ok_or_else(|| "posix_ms add overflow".into())
|
.ok_or_else(|| "posix_ms add overflow".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Pull wallet UTxOs with H-5 strict asset-key parsing.
|
/// Pull wallet UTxOs with H-5 strict asset-key parsing.
|
||||||
///
|
///
|
||||||
/// Shared by every DAO write-path tool that needs to fund + collateralize
|
/// Shared by every DAO write-path tool that needs to fund + collateralize
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue