chore: scrub internal session-log narrative from code comments

Wide sweep across the codebase to remove leftover artifacts of internal
development sessions, internal entity naming, and audit-code references
that point at non-public docs. The technical reasoning for each piece
of code stays; the "Caught 2026-05-XX while debugging XYZ at preprod"
narrative goes.

Categories scrubbed:
- Dated session-log comments ("Caught/Surfaced/Discovered 2026-05-XX")
  → rewritten as neutral technical reasoning.
- Internal audit codes (AUDIT-H2, AUDIT-C2, AUDIT-M2, AUDIT-H5, etc.)
  referencing a non-public audit doc → labels stripped, fix reasoning
  kept.
- Internal-entity names in code comments (Sulkta-specific, Sulkta runs
  X, Terrapin/TRP as gov-token names) → generic phrasing.
- Test fixture helper `sulkta_cfg` → `test_dao_cfg`; test DAO name
  string `"sulkta"` → `"test-dao"`. On-chain addresses in test fixtures
  kept (they're real-world wire-byte test data on public chain).
- Cross-references to memory files / non-public audit docs
  (`internal notes`, `aiken-escrow/README.md`)
  → reasoning inlined or removed.
- Test names renamed: `decodes_sulkta_live_governor_datum` →
  `decodes_live_governor_datum`, `decodes_sulkta_live_proposal_zero` →
  `decodes_live_finished_proposal`, etc.

Kept (legitimate):
- Cross-references to in-repo audit docs (aiken-escrow/README.md, aiken-escrow/README.md) — they ARE the
  public artifacts being referenced.
- HIGH-1/HIGH-2/MED-2/LOW labels on escrow fixes — these correspond to
  findings in the in-repo audit doc.
- TODO markers — legitimate work-still-to-do.
This commit is contained in:
Sulkta 2026-05-10 21:29:40 -07:00
parent 93f0d2ebde
commit 564ba3ccb5
28 changed files with 258 additions and 296 deletions

View file

@ -1,9 +1,10 @@
//! Escrow datum + redeemer encoding.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows.
//! See `aiken-escrow/README.md` for findings.
//!
//! Mirrors the on-chain validator at `aiken-escrow/escrow/validators/escrow.ak`.
//! See `aiken-escrow/README.md` for the full state machine.
//! Mirrors the on-chain validator at `aiken-escrow/validators/escrow.ak`.
//! See `aiken-escrow/README.md` for the state machine.
//!
//! ## Datum shape
//!

View file

@ -26,7 +26,7 @@ pub struct GovernorDatum {
impl GovernorDatum {
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
// ProductIsData → Array, NOT Constr 0.
// Verified against Sulkta's live governor UTxO 2026-05-05.
// Verified against live on-chain governor UTxOs.
Ok(product(vec![
self.proposal_thresholds.to_plutus_data()?,
int(self.next_proposal_id as i128)?,
@ -120,23 +120,18 @@ mod tests {
}
}
/// Decode Sulkta's live governor datum from on-chain CBOR bytes and assert
/// the resulting struct matches the README parameters.
///
/// This is the end-to-end Phase 0 validation: our type port matches what
/// Plutarch actually emits.
///
/// Source: Koios `address_info` for `addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy`
/// at the only governor UTxO `7c8db1432a07143eaf7755257baf8691a8e24ee8b2f3a139fa1ce222f2821c47#1`.
/// Captured 2026-05-05.
/// Decode a real on-chain governor datum from CBOR bytes and
/// assert the resulting struct matches expected parameters.
/// End-to-end test that the type port matches what Plutarch
/// actually emits.
#[test]
fn decodes_sulkta_live_governor_datum() {
fn decodes_live_governor_datum() {
use pallas_primitives::PlutusData;
let cbor_hex = "9f9f14186418640101ff019f1a240c84001a240c84001a0a4cb8001a05265c001a0036ee801a001b7740ff1a001b774014ff";
let bytes = hex::decode(cbor_hex).unwrap();
let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap();
let gov = GovernorDatum::from_plutus_data(&pd).expect("decode Sulkta governor");
let gov = GovernorDatum::from_plutus_data(&pd).expect("decode governor");
assert_eq!(gov.proposal_thresholds.execute, 20);
assert_eq!(gov.proposal_thresholds.create, 100);

View file

@ -35,11 +35,12 @@ pub fn constr(index: u64, fields: Vec<PlutusData>) -> PlutusData {
/// Encode a Plutarch `ProductIsData` record — a CBOR Array, NOT a `Constr 0`.
///
/// **Important:** Plutarch optimizes record encodings via the `ProductIsData`
/// pattern, which serializes as a plain CBOR list of fields rather than the
/// generic-derived `Constr 0 [...]`. Verified against the live Sulkta
/// GovernorDatum UTxO 2026-05-05: outer wire bytes start `9f9f...` (indefinite
/// array of indefinite arrays) — i.e. arrays, not the `d8 79` Constr-121 tag.
/// **Important:** Plutarch optimizes record encodings via the
/// `ProductIsData` pattern, which serializes as a plain CBOR list of
/// fields rather than the generic-derived `Constr 0 [...]`. Verified
/// against live GovernorDatum UTxOs: outer wire bytes start `9f9f...`
/// (indefinite array of indefinite arrays) — arrays, not the `d8 79`
/// Constr-121 tag.
///
/// We emit indefinite-length arrays to match Plutarch's wire output. Both
/// definite and indefinite are accepted on decode (see [`as_array`]).

View file

@ -23,11 +23,9 @@ use crate::error::{DaoError, DaoResult};
/// `data ProposalStatus = Draft | VotingReady | Locked | Finished`
/// via `EnumIsData` → **plain `Integer`** (NOT `Constr i []`).
///
/// **Encoding correction 2026-05-05:** initial Phase 0 spec assumed
/// `EnumIsData` produces `Constr i []`. Real on-chain proposal #0 has
/// status field encoded as bare `BigInt(3)` (CBOR `03`). Plutarch's
/// `EnumIsData` actually emits Integer-as-index in this Agora version.
/// Correction verified by internal notes.
/// Plutarch's `EnumIsData` emits the variant as a bare `BigInt`
/// index in this Agora version. Verified against live on-chain
/// proposal datums.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProposalStatus {
Draft = 0,
@ -325,26 +323,21 @@ mod tests {
}
}
/// Decode Sulkta's Proposal #0 from on-chain bytes. Real-world
/// regression for the type port — same role as the GovernorDatum
/// live-decode test, but for a proposal.
///
/// Source: Koios `address_info` for proposal validator address
/// `addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40`,
/// only UTxO at `0823a9406da1...#0`. Captured 2026-05-05.
/// Decode a real on-chain Agora proposal datum (status=Finished,
/// single cosigner, no votes cast). Regression test against the
/// type port — exercises every field with bytes copied off-chain.
#[test]
fn decodes_sulkta_live_proposal_zero() {
fn decodes_live_finished_proposal() {
use pallas_primitives::PlutusData;
let cbor_hex = "9f00a200a001a1581c92b7725bb0c7c06083af729d38f5589c2f85f16c83fe48860399e96f9f5820046dff6c96199a55715c65b9ae62c3be1b6600038cc3116eeb20886a7d66e83cd87a80ff039fd8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffff9f14186418640101ffa2000001009f1a240c84001a240c84001a0a4cb8001a05265c001a0036ee801a001b7740ff1b0000019c7d5c4d17ff";
let bytes = hex::decode(cbor_hex).unwrap();
let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap();
let prop = ProposalDatum::from_plutus_data(&pd).expect("decode Proposal #0");
let prop = ProposalDatum::from_plutus_data(&pd).expect("decode proposal");
assert_eq!(prop.proposal_id, 0);
assert_eq!(prop.status, ProposalStatus::Finished);
assert_eq!(prop.cosigners.len(), 1);
// second owner pkh
assert!(matches!(
&prop.cosigners[0],
Credential::PubKey(h) if hex::encode(h) == "c5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfda"
@ -355,7 +348,7 @@ mod tests {
assert_eq!(prop.votes.0, vec![(0, 0), (1, 0)]); // zero votes ever cast
assert_eq!(prop.timing_config.draft_time, 7 * 86_400 * 1000);
assert_eq!(prop.timing_config.voting_time, 7 * 86_400 * 1000);
// Decoded from CBOR `1b 0000019c7d5c4d17` = 1771629726999 ms = 2026-04-21 15:42:06 UTC
// CBOR `1b 0000019c7d5c4d17` = 1771629726999 ms
assert_eq!(prop.starting_time, 1_771_629_726_999);
}

View file

@ -19,9 +19,10 @@
//! - The proposal-state-thread minting policy
//! - The GAT minting policy
//!
//! ## Compute-ourselves discovery (Sulkta's pick 2026-05-05)
//! ## Reference-script discovery
//!
//! Per the spec, we don't trust MLabs's published registry. Instead:
//! Rather than trusting an external published registry, refs are
//! discovered from the chain directly:
//!
//! 1. Decode each contract address (governor / stakes / treasury) to
//! extract its payment-credential script hash.

View file

@ -8,7 +8,7 @@
//! - The locked governance tokens (in the value).
//! - A `StakeDatum` (inline datum) carrying owner / delegation / vote-locks.
//! - A "stake state thread" token from the Agora stake-policy minting policy
//! (proves the UTxO is a real stake, not someone sending TRP to the
//! (proves the UTxO is a real stake, not someone sending gov-tokens to the
//! address by accident).
//!
//! This module is the type port + encode/decode only. Tx assembly lives
@ -178,7 +178,7 @@ impl ProposalLock {
/// - `Nothing` → `Constr 1 []`
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StakeDatum {
/// Amount of governance token (TRP) locked. Voting weight.
/// Amount of governance token locked. Voting weight.
pub staked_amount: i64,
/// Stake owner; only this credential may move/destroy this stake.
pub owner: Credential,
@ -348,14 +348,9 @@ mod tests {
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.
/// Decode a real on-chain stake datum. Anchors the StakeDatum
/// type port to a real UTxO so a future encoding refactor can't
/// silently break decode of existing stakes.
#[test]
fn decodes_live_stake_datum() {
use pallas_primitives::PlutusData;
@ -387,7 +382,7 @@ mod tests {
}
/// Same shape as `decodes_live_stake_datum` but for a second
/// stake (250 Terrapin). Two-witness regression — catches drift
/// stake (250 gov-token). Two-witness regression — catches drift
/// even if the first test happens to flatten over a bug.
#[test]
fn decodes_live_stake_datum_b() {

View file

@ -1,6 +1,6 @@
//! Build an unsigned `escrow_agree_unsigned` transaction.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows. See `aiken-escrow/README.md`.
//!
//! ## What this tx does
//!

View file

@ -1,6 +1,6 @@
//! Build an unsigned `escrow_deposit_unsigned` transaction.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows. See `aiken-escrow/README.md`.
//!
//! ## What this tx does
//!

View file

@ -1,6 +1,6 @@
//! Build an unsigned `escrow_open_unsigned` transaction.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows. See `aiken-escrow/README.md`.
//!
//! ## What this tx does
//!

View file

@ -1,6 +1,6 @@
//! Build an unsigned `escrow_refund_timeout_unsigned` transaction.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows. See `aiken-escrow/README.md`.
//!
//! ## What this tx does
//!

View file

@ -1,6 +1,6 @@
//! Build an unsigned `escrow_settle_unsigned` transaction.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows. See `aiken-escrow/README.md`.
//!
//! ## What this tx does
//!

View file

@ -1,6 +1,6 @@
//! Build an unsigned `escrow_veto_unsigned` transaction.
//!
//! ⚠️ Not third-party audited — preprod-only. See `aiken-escrow/README.md`.
//! ⚠️ Not third-party audited — use-at-own-risk for high-value flows. See `aiken-escrow/README.md`.
//!
//! ## What this tx does
//!

View file

@ -12,9 +12,9 @@
//! | 4b | `proposal_cosign` | Add additional cosigner to a Draft proposal |
//! | 3 | `proposal_vote` | Spend stake (PermitVote) + proposal (Vote tag) |
//! | 4c | `proposal_advance` | State-machine transition redeemer |
//! | 4d | `stake_destroy` | Spend stake (Destroy), return TRP to wallet |
//! | 4d | `stake_destroy` | Spend stake (Destroy), return gov-tokens to wallet |
//! | 4e | `treasury_execute` | Burn GAT + spend treasury per effect datum |
//! | def. | `stake_create` | Lock TRP at stakes script (deferred — both |
//! | def. | `stake_create` | Lock gov-tokens at stakes script (deferred — both |
//! | | | live wallets already have stakes) |
pub mod escrow_agree;

View file

@ -55,10 +55,11 @@ use crate::error::{DaoError, DaoResult};
/// Per-script ExUnits budget for proposal_create.
///
/// **AUDIT-H2 fix 2026-05-05:** Original values were 14M mem / 10G steps
/// each — equal to per-tx Conway max. With 3 plutus contracts firing
/// (governor spend + stake spend + ProposalST mint), the total claim
/// would exceed the per-tx cap and node rejects pre-phase-2.
/// With 3 Plutus contracts firing in this tx (governor spend +
/// stake spend + ProposalST mint), the per-script claim must be
/// significantly under the per-tx Conway max (14M mem / 10G steps),
/// otherwise the node rejects the tx pre-phase-2 even before scripts
/// run.
///
/// The reference tx (`7c8db1432a07...`) used 1208B tx size + 573_553
/// lovelace fee, suggesting much smaller ExUnits per script. Drop to
@ -115,21 +116,22 @@ pub struct GovernorUtxoIn {
/// 56-hex Governor State Thread (GST) policy id. The new governor
/// output must carry +1 of this token to keep the singleton invariant.
pub gst_policy_hex: String,
/// Asset name (hex) of the GST token. Empty for Sulkta.
/// Asset name (hex) of the GST token. Often empty.
pub gst_asset_name_hex: String,
}
/// On-chain stake state we need to spend (proposer's existing stake).
///
/// AUDIT-C2 fix 2026-05-05: governor's `CreateProposal` branch hard-asserts
/// `Stake input should present`. Builder MUST take a stake utxo to spend.
/// The owner of the stake's datum must equal the tx's signer (proposer_pkh).
/// The governor's `CreateProposal` branch hard-asserts "Stake input
/// should present" — the builder MUST take a stake utxo to spend.
/// The owner of the stake's datum must equal the tx's signer
/// (proposer_pkh).
#[derive(Debug, Clone)]
pub struct StakeUtxoIn {
pub tx_hash_hex: String,
pub output_index: u32,
pub lovelace: u64,
/// Current Terrapin/gov-token quantity on the UTxO. Must equal
/// Current gov-token quantity on the UTxO. Must equal
/// `datum.staked_amount` per stake validator invariant.
pub gov_token_qty: u64,
/// StakeST asset name (= stake validator script hash) for the +1 token
@ -169,7 +171,7 @@ impl ReferenceUtxo {
pub struct ProposalCreateArgs {
pub cfg: DaoConfig,
pub governor: GovernorUtxoIn,
/// Proposer's existing stake UTxO. AUDIT-C2 — required for the
/// Proposer's existing stake UTxO. required for the
/// governor's CreateProposal branch to find a stake input. The stake's
/// owner pkh must equal `proposer_pkh`, and `staked_amount + deposit`
/// must clear `governor.proposal_thresholds.create`.
@ -194,12 +196,12 @@ pub struct ProposalCreateArgs {
/// validRange` by construction.
pub starting_time_slot: u64,
/// Current chain tip slot. Retained for caller-side fee/sanity
/// math; no longer drives the validity range as of 2026-05-07
/// (see `starting_time_slot` above).
/// math; doesn't drive the validity range — that anchors on
/// `starting_time_slot` above.
pub tip_slot: u64,
/// Reference UTxO to cite for the governor validator script.
pub governor_validator_ref: ReferenceUtxo,
/// Reference UTxO to cite for the stake validator script. AUDIT-C2.
/// Reference UTxO to cite for the stake validator script.
pub stake_validator_ref: ReferenceUtxo,
/// Reference UTxO to cite for the ProposalST minting policy script.
pub proposal_st_policy_ref: ReferenceUtxo,
@ -242,7 +244,7 @@ pub fn build_unsigned_proposal_create(
// ---- preflight: stake's owner must match proposer; stake meets create-threshold ----
//
// AUDIT-C2 + governor's `CreateProposal` invariants. Catch these
// Governor's `CreateProposal` invariants. Catch these
// client-side rather than waste fees on a phase-2 reject.
if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.proposer_pkh) {
return Err(DaoError::State("stake owner pkh does not match proposer pkh — proposer must own the stake input".to_string()));
@ -319,11 +321,12 @@ pub fn build_unsigned_proposal_create(
// Proposal: fresh ProposalDatum (Draft, sole cosigner, copied params).
//
// AUDIT-C1 fix 2026-05-05: effects must be a NON-empty map with at least
// one neutral (empty inner map) entry, AND its keys must equal the votes
// map's keys. Per Agora `Governor/Scripts.hs:437-462` validators
// Effects must be a NON-empty map with at least one neutral (empty
// inner map) entry, AND its keys must equal the votes map's keys.
// Per Agora `Governor/Scripts.hs:437-462`, validators
// `phasNeutralEffect` (pany # pnull over inner maps) and
// `pisEffectsVotesCompatible` (effects keys == votes keys).
// `pisEffectsVotesCompatible` (effects keys == votes keys) both
// hard-fail without this.
//
// For InfoOnly: both ResultTag(0) and ResultTag(1) map to empty inner
// maps (no effect scripts trigger regardless of vote outcome).
@ -354,9 +357,8 @@ pub fn build_unsigned_proposal_create(
// proposal. Order matters — the stake validator's ppermitVote uses
// `pcons NEW_LOCK old_locks` (head-cons, NOT append). If we append
// and the input had pre-existing locks, the chain rejects with
// CekError on the stake validator. Caught 2026-05-08 trying to
// create proposal #1 while the stake still held a Created lock
// from proposal #0; cosign + vote builders already prepend.
// CekError on the stake validator. Cosign + vote builders already
// prepend on the same invariant.
let mut new_locks = Vec::with_capacity(args.stake_in.datum.locked_by.len() + 1);
new_locks.push(ProposalLock {
proposal_id: new_proposal_id,
@ -378,19 +380,16 @@ pub fn build_unsigned_proposal_create(
// ---- redeemers --------------------------------------------------------
//
// Governor spend: GovernorRedeemer::CreateProposal = Integer 0 (per
// EnumIsData encoding fix 2026-05-05).
// Governor spend: GovernorRedeemer::CreateProposal = Integer 0
// (per EnumIsData encoding — variant index as a bare Integer).
//
// Stake spend: redeemer is PermitVote (Constr 2 []). DepositWithdraw
// requires locked_by to STAY empty — which conflicts with adding a
// Created lock for the new proposal. PermitVote is the redeemer that
// grants new locks (for create/vote/cosign) on a stake. Caught
// 2026-05-07 PM via base64-decoded failing-script header (5178 bytes
// = stake validator); the bare CekError under traces-stripped Agora
// pointed at the stake's lock-state invariant.
// Created lock for the new proposal. PermitVote is the redeemer
// that grants new locks (for create/vote/cosign) on a stake.
//
// Mint redeemer: per `Agora/Proposal/Scripts.hs:118` the policy is
// `\_gst _redeemer ctx -> ...` — redeemer is unused. Constr 0 [] is fine.
// `\_gst _redeemer ctx -> ...` — redeemer unused. Constr 0 [] is fine.
let governor_spend_redeemer_cbor = minicbor::to_vec(&crate::agora::plutus_data::int(0)?)
.map_err(|e| DaoError::Cbor(format!("governor spend redeemer encode: {e}")))?;
@ -429,7 +428,7 @@ pub fn build_unsigned_proposal_create(
))
})?;
// Wallet change can be a regular pubkey output — lower min-utxo floor.
// AUDIT-M2: previous code required script-floor (2 ADA) for wallet
// Previous code required script-floor (2 ADA) for wallet
// change; that's wrong, use 1 ADA (still conservative for Conway).
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
@ -585,7 +584,7 @@ pub fn build_unsigned_proposal_create(
Some(PROPOSAL_CREATE_MINT_EX_UNITS),
);
// AUDIT-C3 fix: tx validity range + disclosed_signer.
// tx validity range + disclosed_signer.
//
// pvalidateProposalStartingTime requires a bounded validRange ≤
// create_proposal_time_range_max_width that includes starting_time.
@ -603,26 +602,26 @@ pub fn build_unsigned_proposal_create(
as u64)
.saturating_sub(1)
.min(VALIDITY_RANGE_SLOTS);
// 2026-05-07: anchor the validity range to caller-supplied
// `starting_time_slot` instead of `tip_slot`. Public Koios's tip
// endpoint can lag the actual chain by 100+ slots; under a 29s
// governor window that lag pushes invalid_after into the past
// before the tx ever reaches a node. Caller passes a slightly-future
// starting_time_slot (e.g. tip+30); the on-chain
// Anchor the validity range to caller-supplied `starting_time_slot`
// instead of `tip_slot`. Public Koios's tip endpoint can lag the
// actual chain by 100+ slots; under a tight governor window that
// lag pushes `invalid_after` into the past before the tx ever
// reaches a node. Caller passes a slightly-future
// `starting_time_slot` (e.g. tip+30); the on-chain
// `OutsideValidityIntervalUTxO` check then has a window that
// straddles when the tx actually lands, while the in-script
// `pvalidateProposalStartingTime` is satisfied because
// `starting_time_slot ∈ [valid_from, invalid_after - 1]` by
// construction.
// 2026-05-08: CENTER `starting_time_slot` inside the validity range
// (rather than putting it at the lower bound). Tiny test DAOs run on
// a 30-second create_proposal_time_range_max_width, and koios's tip
// endpoint lag vs. the actual node can swing ±60s. With
// valid_from = starting_time, the window only spans [now, now+30].
// If chain is even slightly past `now` when the tx lands, the tx
// expires. Centering gives [now-15, now+15] of slack — same width,
// same validator-bound, but the chain-now-at-block-time can drift
// ±15s without missing the window.
//
// CENTER `starting_time_slot` inside the validity range (not at
// the lower bound). For tight test-governor windows (30 s), Koios
// tip lag vs. actual chain time can swing ±60 s — with
// `valid_from = starting_time` the window only spans [now, now+30],
// so any chain drift past `now` expires the tx. Centering gives
// [now-15, now+15] of slack — same width, same validator-bound,
// but the chain-now-at-block-time can drift ±15s without missing
// the window.
let half_width_slots = max_width_slots / 2;
let valid_from = args.starting_time_slot.saturating_sub(half_width_slots);
let invalid_from = valid_from + max_width_slots;
@ -639,12 +638,11 @@ pub fn build_unsigned_proposal_create(
staging = staging.fee(args.fee_lovelace).network_id(network_id);
// Wire the V2 cost model so pallas computes script_data_hash. Without
// this the chain rejects with PPViewHashesDontMatch — same trap the
// plutus_mint path tripped over on 2026-05-07. All Agora validators
// we witness here (governor, stake, proposalSt policy) are PlutusV2
// on the current preprod linker output, so a single language_view
// entry covers all three.
// Wire the V2 cost model so pallas computes script_data_hash.
// Without it the chain rejects with PPViewHashesDontMatch. All
// Agora validators witnessed here (governor, stake, proposalSt
// policy) are PlutusV2 in the current linker output, so a single
// language_view entry covers all three.
staging = staging.language_view(
ScriptKind::PlutusV2,
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),

View file

@ -1,7 +1,7 @@
//! Build a `dao_stake_destroy` transaction.
//!
//! Destroys a stake UTxO, burning its StakeST token and returning the
//! locked governance tokens (TRP for Sulkta) + lovelace to the owner's
//! locked governance tokens + lovelace to the owner's
//! wallet.
//!
//! ## Tx shape

View file

@ -55,7 +55,7 @@ pub enum DaoNetwork {
}
/// One named DAO. Captures every Sulkta-specific value as an
/// One named DAO. Captures every per-DAO value as an
/// instance field so the rest of the crate is config-driven.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaoConfig {
@ -380,17 +380,17 @@ mod tests {
fn register_makes_first_dao_active() {
let dir = tempdir().unwrap();
let store = DaoStore::new(dir.path());
store.register(&cfg("sulkta")).unwrap();
assert_eq!(store.get_active().unwrap().name(), "sulkta");
store.register(&cfg("test-dao")).unwrap();
assert_eq!(store.get_active().unwrap().name(), "test-dao");
}
#[test]
fn second_register_does_not_change_active() {
let dir = tempdir().unwrap();
let store = DaoStore::new(dir.path());
store.register(&cfg("sulkta")).unwrap();
store.register(&cfg("test-dao")).unwrap();
store.register(&cfg("bobs_dao")).unwrap();
assert_eq!(store.get_active().unwrap().name(), "sulkta");
assert_eq!(store.get_active().unwrap().name(), "test-dao");
}
#[test]
@ -414,8 +414,8 @@ mod tests {
fn remove_clears_active_if_was_active() {
let dir = tempdir().unwrap();
let store = DaoStore::new(dir.path());
store.register(&cfg("sulkta")).unwrap();
store.remove("sulkta").unwrap();
store.register(&cfg("test-dao")).unwrap();
store.remove("test-dao").unwrap();
assert!(store.get_active().is_err());
}
@ -423,16 +423,16 @@ mod tests {
fn resolve_falls_through_to_active() {
let dir = tempdir().unwrap();
let store = DaoStore::new(dir.path());
store.register(&cfg("sulkta")).unwrap();
store.register(&cfg("test-dao")).unwrap();
let cfg = store.resolve(None).unwrap();
assert_eq!(cfg.name, "sulkta");
assert_eq!(cfg.name, "test-dao");
}
#[test]
fn resolve_named_overrides_active() {
let dir = tempdir().unwrap();
let store = DaoStore::new(dir.path());
store.register(&cfg("sulkta")).unwrap();
store.register(&cfg("test-dao")).unwrap();
store.register(&cfg("bobs_dao")).unwrap();
let cfg = store.resolve(Some("bobs_dao")).unwrap();
assert_eq!(cfg.name, "bobs_dao");
@ -440,14 +440,14 @@ mod tests {
#[test]
fn validate_rejects_bad_name() {
let mut c = cfg("sulkta");
let mut c = cfg("test-dao");
c.name = "Sulkta DAO".into(); // uppercase + space
assert!(c.validate().is_err());
}
#[test]
fn validate_rejects_short_policy() {
let mut c = cfg("sulkta");
let mut c = cfg("test-dao");
c.gov_token_policy = "abc".into();
assert!(c.validate().is_err());
}

View file

@ -1,8 +1,12 @@
//! Auto-discover Agora script hashes + reference UTxO refs from on-chain state.
//! Auto-discover Agora script hashes + reference UTxO refs from
//! on-chain state.
//!
//! Closes the "user has to research and hand-populate ScriptRefs" gap by
//! running the same Koios queries the human audit at
//! `memory/internal notes` performed.
//! Closes the "user has to research and hand-populate ScriptRefs"
//! gap by running the Koios queries that would otherwise be done by
//! hand: enumerate UTxOs at the deployer / stakes address, match
//! `reference_script.hash` entries against the script hashes
//! extracted from the configured governor + stakes + treasury
//! addresses.
//!
//! ## What we discover from the existing config
//!
@ -195,11 +199,12 @@ pub async fn discover_scripts(
//
// A stake UTxO carries (gov_token, qty) + (stake_st_token, 1).
//
// **AUDIT-H6 fix 2026-05-05:** Previous logic was "first non-gov-token
// asset on a stake UTxO" — would silently pick a wrong asset if anyone
// ever sent a junk NFT to a stake UTxO (Cardano allows this). Tighten:
// the StakeST minting policy mints with `asset_name = stake validator's
// script hash` per `Stake/Scripts.hs:188-190` (`pscriptHashToTokenName`).
// Tight match: the StakeST minting policy mints with
// `asset_name = stake validator's script hash` per
// `Stake/Scripts.hs:188-190` (`pscriptHashToTokenName`).
// A naive "first non-gov-token asset" match would silently pick
// a wrong asset if anyone sent a junk NFT to a stake UTxO
// (Cardano allows this).
// Match on that explicitly.
match client.address_info(&cfg.stakes_addr).await {
Ok(infos) => {
@ -397,10 +402,10 @@ mod tests {
}
}
fn sulkta_cfg() -> DaoConfig {
fn test_dao_cfg() -> DaoConfig {
use crate::config::ScriptRefs;
DaoConfig {
name: "sulkta".into(),
name: "test-dao".into(),
description: None,
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
@ -421,7 +426,7 @@ mod tests {
#[tokio::test]
async fn discovers_stake_st_from_existing_stake() {
let cfg = sulkta_cfg();
let cfg = test_dao_cfg();
let mut responses = std::collections::HashMap::new();
// A fake stake UTxO at stakes_addr carrying gov-token + StakeST.
// StakeST asset_name == Sulkta stake validator hash (per H-6 fix).
@ -472,7 +477,7 @@ mod tests {
#[tokio::test]
async fn finds_validator_refs_at_deployer() {
let cfg = sulkta_cfg();
let cfg = test_dao_cfg();
let governor_hash = "d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7";
let stake_hash = "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4";
@ -545,7 +550,7 @@ mod tests {
#[test]
fn apply_discovery_merges_into_config() {
let mut cfg = sulkta_cfg();
let mut cfg = test_dao_cfg();
let report = DiscoveryReport {
governor_validator_ref: Some("aa#1".into()),
stake_validator_ref: Some("bb#2".into()),
@ -562,7 +567,7 @@ mod tests {
#[test]
fn apply_discovery_doesnt_overwrite_existing() {
let mut cfg = sulkta_cfg();
let mut cfg = test_dao_cfg();
cfg.stake_st_policy = Some("preexisting".into());
let report = DiscoveryReport {
stake_st_policy: Some("would_overwrite".into()),
@ -572,12 +577,12 @@ mod tests {
assert_eq!(cfg.stake_st_policy.as_deref(), Some("preexisting"));
}
/// Regression for AUDIT-H6: a stake UTxO with a junk third-party token
/// must NOT pollute StakeST detection. The StakeST is only detected
/// when its `asset_name == stakes_validator_script_hash`.
/// Regression: a stake UTxO with a junk third-party token must NOT
/// pollute StakeST detection. The StakeST is only detected when its
/// `asset_name == stakes_validator_script_hash`.
#[tokio::test]
async fn h6_junk_token_does_not_pollute_stake_st_detection() {
let cfg = sulkta_cfg();
async fn junk_token_does_not_pollute_stake_st_detection() {
let cfg = test_dao_cfg();
let mut responses = std::collections::HashMap::new();
responses.insert(
cfg.stakes_addr.clone(),

View file

@ -21,9 +21,9 @@
//! - Not a key store. Signing is delegated to `aldabra-core`.
//! - Not an MCP server. The `dao_*` tools that wrap these primitives
//! live in `aldabra-mcp`.
//! - Not Sulkta-specific. Every Sulkta value (TRP policy, governor
//! address, etc) comes from a [`config::DaoConfig`] loaded at
//! runtime, never compile-time.
//! - Not specific to any single DAO. Every per-DAO value (governance
//! token policy, governor / stakes / treasury addresses, etc.) comes
//! from a [`config::DaoConfig`] loaded at runtime, never compile-time.
pub mod agora;
pub mod builder;

View file

@ -39,7 +39,7 @@ pub struct StakeUtxo {
pub datum: StakeDatum,
/// Lovelace at this UTxO.
pub lovelace: u64,
/// Gov-token (TRP) quantity at this UTxO. Should equal
/// Gov-token quantity at this UTxO. Should equal
/// `datum.staked_amount` when the validator is correctly enforcing.
pub gov_token_quantity: u64,
}