Commit graph

51 commits

Author SHA1 Message Date
Sulkta
b69d891a2b chore: strip audit-ticket prefixes from code comments
Drops the ~60 ticket-prefix comments (CRIT-N, HIGH-N, MED-N, LOW-N,
L-N, M-N, AUDIT-N, PLUTUS-N, "audit fix (date):", "Phase N" labels,
"Adversarial-review fix:") that had accumulated in inline + doc
comments over several audit cycles. Where the surrounding prose
still carried useful WHY context it gets kept and tightened; where
the ticket WAS the comment it gets dropped entirely.

No logic, no renames, no behavior change. Audit history lives in
commit messages and the audits/ tree where it belongs — eternal
comments don't need to mirror it.

Net 138 LOC shorter. 253 tests pass, no new clippy or fmt warnings.
2026-05-12 14:42:13 -07:00
Sulkta
564ba3ccb5 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.
2026-05-10 21:29:40 -07:00
Sulkta
93f0d2ebde docs: rewrite for users — drop internal infra context
README + supporting docs were written for ourselves (deployment paths,
internal product comparisons, internal task lists, build pipeline
artifacts) instead of for users of the software. This pass refocuses
them on what the software is, how to install, configure, and use it.

- README.md: full rewrite. New shape — What it does / Architecture /
  Build / Run / Configuration / MCP tools / Security model / Status /
  License / Dependencies. Drops the internal "why we built it"
  narrative, drops phase-status claims that drifted stale, drops
  internal deployment paths.
- ROADMAP.md: deleted. Was an internal task-list with [x]/[ ] items
  showing incremental private development. The README's Status
  section now communicates what's actually shipped.
- docs/architecture.md: scrub cross-project comparisons referencing
  unrelated internal Sulkta codebases.
- aiken-escrow/README.md: drop reference to a non-existent spec file;
  rewrite the Status checklist to reflect what's actually done
  rather than what was open at the time of writing.
- aiken-escrow/README.md: scrub internal image names +
  container paths; the audit findings (chain hashes, validator hash,
  what each tx proved) are the public-useful part and stay.
- aiken-escrow/README.md: drop references to
  feature-flag-gated branches that no longer exist.
- Dockerfile: drop the dead `escrow_wip surface` phrase from comments.
- Cargo.toml: drop the cross-project comparison comment that named
  an unrelated internal service.
- crates/aldabra-{core,dao}: scrub internal preprod-test naming from
  source comments — same technical content, generic phrasing.
2026-05-10 20:56:25 -07:00
Sulkta
fc29ad6e03 feat(escrow): drop escrow_wip feature gate, ship as default surface
Per Sulkta 2026-05-09 directive: after the audit + preprod E2E green-light
(6/6 builders, 9 successful txs, 0 failures), drop the compile-time gate
and integrate escrow as a default-on feature. The "not third-party
audited" framing becomes a runtime notice carried by escrow_open_unsigned
rather than a Cargo feature.

Changes:
- aldabra-dao/Cargo.toml: drop [features] block + escrow_wip = []
- aldabra-dao/src/agora/mod.rs: pub mod escrow (no cfg gate)
- aldabra-dao/src/builder/mod.rs: 6 escrow_* modules unconditional
- aldabra-mcp/Cargo.toml: drop features = ["escrow_wip"] from dao dep
- aldabra-mcp/src/tools.rs:
  - Drop "WIP — UNAUDITED:" prefix from all 6 escrow tool descriptions
  - Drop "wip_warning" JSON field from all 6 spend-tool responses
  - Add "audit_notice" field on escrow_open_unsigned response only
    (per Sulkta's framing — once-per-escrow-conversation, not repeated
    on every subsequent tool)
  - Update section header comment to reflect post-WIP status
- 7 escrow source files (1 agora + 6 builder): replace
  "WIP / UNAUDITED. Feature-gated behind escrow_wip" docstring with
  "Not third-party audited — preprod-only" + audit doc reference

Verified: 133 dao tests pass (was 132 under --features escrow_wip;
+1 from the rejects_no_initial_contributor test that's now always
compiled). aldabra-mcp release build clean.

The runtime audit_notice on escrow_open_unsigned reads:
  "This escrow validator has had an internal review and a 9-tx preprod
   E2E pass, but has NOT been audited by an external third party. Use
   at your own risk. If the user is opening this with anything beyond
   test-net or low-value funds, pass this notice along and confirm they
   accept the risk. Validator hash: a8081acef26935d9b5f44b92052178e17301b6d6e6808c91c5b56f5d."

This carries the same caveat the WIP framing did, but in a form the
calling agent can surface inline to the user opening the escrow.
2026-05-09 23:08:30 -07:00
Sulkta
7daa62b5e5 fix(escrow_wip): apply 2026-05-09 internal audit findings
Two HIGH validator-side bugs + several MED/LOW off-chain issues found
in the subagent-driven audit on this branch. New validator hash:
a8081acef26935d9b5f44b92052178e17301b6d6e6808c91c5b56f5d.

## HIGH-1: Deposit redeemer let depositors drain tokens

aiken-escrow/validators/escrow.ak Deposit branch now requires
`value_geq_value(new_value, in_value)` before computing net_added.
Previously net_added could carry negative quantities (when new_value
< in_value component-wise), letting a depositor write a matching
new_d.deposits with reduced values and pocket the difference as
wallet change. Latent under v1 ADA-only MCP usage but the validator
must hold against all callers.

## HIGH-2: Empty/partial deposits enabled funds drain via Veto/Refund

Veto and Refund branches now require
`value_eq(deposits_to_value(d.deposits), in_value)` — the tracked
deposits must account for the full locked value. Previously
`refund_outputs_satisfy(_, [])` was vacuously true on empty deposits,
so a driver could fire Veto/Refund on an escrow opened with
`initial_contributor=None` (deposits=[], in_value>0) and pocket the
input's lovelace as change.

Defense in depth: escrow_open builder now refuses
`initial_contributor=None`. New helper `deposits_to_value` folds
deposit FlatValues into a Value via `assets.add` for the equality
check.

## MED: off-chain fixes

- escrow_open min-utxo bumped 1M → 2M (Conway-era inline-datum
  + script-address outputs need ~1.4-1.7 ADA, NOT the 1 ADA default).
- escrow_settle_unsigned + escrow_refund_timeout_unsigned now derive
  `validity_lower_ms` via slot_to_posix_ms(network, slot) instead of
  Koios's `block_time*1000` — the chain reconstructs `lower` from the
  slot, so Koios's ~1s drift could pass off-chain preflight while the
  chain rejects at the strict-`>` boundary.
- escrow_open_unsigned MCP tool no longer accepts (and silently
  discards) `fee_lovelace` — the unsigned-tx builder auto-estimates.

## LOW: defensive depth

- escrow_veto + escrow_refund_timeout: `qty as u64` → `u64::try_from`
  so a corrupt or adversarial datum with negative i128 qty can't slip
  through with a wraparound.

## Tests

- 36 escrow builder tests pass (added rejects_no_initial_contributor)
- 132 dao tests pass under --features escrow_wip
- aldabra-mcp release build clean

## Infra

- Validator artifact files (plutus.json, validator.cbor.hex)
  regenerated. Dockerfile already wired to bake them at
  /etc/aldabra/escrow/ for MCP tools' validator_script_path arg.
- Internal audit findings written up at
  aiken-escrow/README.md including the v2-deferred
  items (multi-asset spend-input, lovelace-not-cross-checked, etc.)

Third-party audit still required before any mainnet deployment.
2026-05-09 14:06:17 -07:00
Sulkta
ef38ff0e57 feat(escrow_wip): MCP escrow spend-tool surface (deposit/agree/veto/settle/refund_timeout)
Five new MCP tools wrapping the four Plutus V3 spend builders shipped
earlier in this branch:

- escrow_deposit_unsigned     → Deposit redeemer (continuing-output state)
- escrow_agree_unsigned       → Agree redeemer (Open → Agreed{at=upper}, both sign)
- escrow_veto_unsigned        → Veto redeemer (Agreed → multi-output refund)
- escrow_settle_unsigned      → Settle redeemer (Agreed → recipient payout)
- escrow_refund_timeout_unsigned → Refund redeemer (Open after open_deadline → multi-output refund)

Each takes the existing escrow UTxO ref + lovelace + datum_cbor_hex
(caller pulls via chain_address_info), the V3 validator UPLC
(inline hex OR file-path to dodge the >4500-char MCP transport bug),
the redeemer-specific args, and fee_lovelace. Validity windows
default to 30 min from chain tip; agree's window auto-clamps to
open_deadline_ms when needed.

Helper additions:
- EscrowDatum::from_cbor_hex on aldabra-dao keeps pallas-codec /
  pallas-primitives direct deps OUT of aldabra-mcp.
- decode_pkh28, resolve_validator_required, build_escrow_spend_in,
  fetch_tip_slot_ms in tools.rs — small helpers shared by all 5
  spend tools.

Drops the [features] section on aldabra-mcp's Cargo.toml. rmcp 0.1.5's
#[tool(tool_box)] macro scans the impl AST and references every
#[tool]-annotated method's generated wrapper regardless of cfg
eligibility — cfg-on-method gating fails to compile when the feature
is off because the macro emits unresolved symbol references. Pivot:
always-pull aldabra-dao/escrow_wip via the dep itself. The runtime
gate is the "WIP — UNAUDITED:" prefix in every tool description plus
the "wip_warning" field in JSON responses; the dao crate's escrow_wip
feature still gates downstream Rust consumers that want source-level
opt-out.

Verified: aldabra-mcp builds clean (default + release). 132 aldabra-
dao tests pass under --features escrow_wip including all 35 escrow
builder tests. Release binary produced.
2026-05-09 13:36:44 -07:00
Sulkta
54a90a5f8d feat(escrow_wip): MCP escrow_open_unsigned tool + Cargo feature
Adds the first MCP tool surface for the escrow validator: takes
party_a/b/recipient pkh hex + open_deadline + lock_period + optional
initial_contributor + initial_lovelace, calls
build_unsigned_escrow_open, returns CBOR-hex of the unsigned tx.

Tool description prefixed with "WIP — UNAUDITED:" per the handoff
discipline. Body includes a wip_warning field reminding callers this
is preprod-only.

Also propagates the escrow_wip feature from aldabra-mcp to
aldabra-dao so building the MCP binary with --features escrow_wip
correctly enables the dao crate's escrow surface.

Cleaned up unused-import warnings (ESCROW_SPEND_EX_UNITS pulled to
test-only scope in agree/settle/veto/refund_timeout — the impl bodies
take ex_units explicitly via args, only tests reference the constant).
35/35 escrow builder tests still pass; aldabra-mcp builds clean with
--features escrow_wip.

The remaining 4 unsigned-write tools (deposit/agree/veto/settle/
refund_timeout) follow the same pattern: pull wallet utxos, optionally
discover the escrow UTxO from chain at script_address, decode datum,
call the corresponding builder, return CBOR-hex JSON.
2026-05-09 12:49:46 -07:00
Sulkta
0feb0800dd feat(escrow_wip): build_unsigned_escrow_refund_timeout builder
Plutus V3 spend that consumes an Open escrow whose open_deadline has
elapsed and refunds every contributor. Same multi-output refund shape
as Veto, different validator gates: state==Open AND lower >
open_deadline_ms (strict gt). No required signer — time gate is the
only gate.

Reuses enterprise_address_for from escrow_veto. 5 tests: not-Open
reject, open-window-not-elapsed reject (off-by-one strict gt),
empty-escrow reject, two-contributor full refund happy path,
outsider driver works.

Closes the 5-redeemer escrow surface: open + deposit + agree + veto +
settle + refund_timeout. 35/35 escrow builder tests pass.
2026-05-09 12:42:16 -07:00
Sulkta
3cf61c79da feat(escrow_wip): build_unsigned_escrow_settle builder
Plutus V3 spend that consumes an Agreed escrow whose lock window has
elapsed and pays the entire in_value to the recipient's enterprise
address. Validator gate: state==Agreed AND lower > agreed_at_ms +
lock_period_ms (strict gt). No signer required by validator.

Driver pays fee via funding utxo + collateral; doesn't need to be a
party (test asserts this — anyone can push Settle once the lock
elapses).

Made enterprise_address_for pub(super) in escrow_veto so settle and
refund_timeout can share it. Mirrors the validator's
pkh_to_base_address byte-for-byte.

5 tests: not-Agreed reject, lock-not-elapsed reject (off-by-one strict
gt), empty-escrow reject, happy-path pays full in_value, outsider
driver works.
2026-05-09 12:40:06 -07:00
Sulkta
aeac3e8e0a feat(escrow_wip): build_unsigned_escrow_veto builder
Plutus V3 spend that consumes an Agreed escrow and refunds every
contributor to their enterprise (null-stake) address. Either party
can fire — validator gate is signed_by(a) || signed_by(b).

Off-chain reconstructs the validator's pkh_to_base_address via
ShelleyAddress::new(net, payment=key_hash(pkh), delegation=Null).
Below-floor deposits are topped up from the driver's funding utxo
to keep refund outputs submittable (validator's value_geq_flat
permits paying more than the deposit value).

7 tests: not-Agreed reject, outsider-driver reject, no-deposits
reject, two-contributor full refund happy path, below-floor topup,
either-party-drives, enterprise-addr construction is testnet for
preprod (sanity check on the network bit).
2026-05-09 12:37:51 -07:00
Sulkta
415c099240 feat(escrow_wip): build_unsigned_escrow_agree builder
Plutus V3 spend that flips state Open → Agreed { agreed_at_ms = upper }.
Both party_a and party_b sign — driver returns partially-signed CBOR,
co-signer adds witness via wallet_sign_partial, signed tx submitted.

Validator enforces validity_upper ≤ open_deadline_ms (preflighted),
value preserved bit-exact, deposits preserved bit-exact via cbor.serialise
equality (passes naturally since we copy the Vec verbatim and
to_plutus_data emits deterministic KeyValuePairs).

7 tests cover negative paths (non-Open state, outsider driver,
post-deadline upper) and happy paths (a-drives, b-drives, datum
state flips correctly, immutable fields + deposits preserved).
2026-05-09 12:34:57 -07:00
Sulkta
ec3e98a1c5 feat(escrow_wip): build_unsigned_escrow_deposit builder
Plutus V3 spend with continuing-output state transition. Validator runs
Deposit { contributor } redeemer.

v1 limitation: ADA-only deposits. Multi-asset deposits are deferred —
Aiken's flat_merge uses right-fold which produces REVERSED appended-
policy ordering vs naive forward iteration. Single-entry net_added
(lovelace-only) trivially collapses any ordering ambiguity, so Rust's
existing value_merge matches Aiken's flat_merge byte-for-byte for v1.

Mirrors all six validator checks client-side as preflight (state==Open,
contributor in {a,b}, signed_by, continuing-output exists, datum equal
except deposits, deposits canonicality). 9 unit tests cover both
negative paths and happy-path datum mutations (new entry append,
existing entry merge, immutable fields preserved).

Inlines V3 validator CBOR in the tx witness for v1; reference-script
optimization is a v2 follow-up. Sets V3 cost model via language_view
to satisfy script_data_hash.
2026-05-09 12:29:12 -07:00
Sulkta
23dfbbae80 feat(escrow_wip): build_unsigned_escrow_open builder
⚠ WIP — UNAUDITED. Feature-gated.

The simplest of the five escrow paths: create a fresh escrow UTxO at
the validator script address with an inline EscrowDatum + initial
deposit (or empty deposits for two-step funding). No Plutus spend
involved — this is just a wallet send to the script address with the
typed datum encoded as inline CBOR.

Wraps aldabra-core::build_unsigned_payment_extras to add:
- Typed EscrowDatum construction (avoids hand-encoding CBOR)
- Preflight: initial_contributor must be party_a or party_b
- Preflight: initial_lovelace clears protocol min_utxo floor
- Standard summary string for MCP wrappers

Tests: 2 preflight rejections (unauthorized contributor, sub-min-utxo
funding) plus the 10 codec roundtrips. All 12 pass under
`cargo test -p aldabra-dao --features escrow_wip escrow`.

Workspace also builds clean with default features (escrow_wip disabled);
the entire escrow surface compiles out of release builds.

Remaining builders (deposit / agree / veto / settle / refund) all
involve script spends, continuing-output datum diffs, and time-bound
validity — deferred to next session for proper care.
2026-05-09 11:42:39 -07:00
Sulkta
aff39a668f feat(escrow_wip): EscrowDatum / EscrowRedeemer / EscrowValue codecs
⚠ WIP — UNAUDITED. Feature-gated behind `escrow_wip`; out of default builds.

Mirrors aiken validator at aiken-escrow/escrow/validators/escrow.ak (also
WIP). Spec at aiken-escrow/README.md.

Five-redeemer two-party agreement-with-veto escrow:
- Open → both sign Agree → Agreed{at} → Settle (after lock_period)
- Agreed → either party Veto → per-contributor refund
- Open → Refund (after open_deadline) → per-contributor refund

Datum: ProductIsData (Constr 0 [a, b, recipient, deadline, lock, state, deposits]).
EscrowState: enum Open | Agreed{at} encoded as Constr 0/1 (with payload).
EscrowDeposit: per-contributor (pkh, Value) entry, Constr 0.
EscrowValue: Plutus Map<PolicyId, Map<AssetName, Int>>; preserves on-chain
ordering for cbor-equality checks the validator does on continuing-output
deposits diff.

Tests: 10 codec roundtrips (Open/Agreed states, ada-only/multi-asset values,
deposit lookup by pkh, value-merge sum). All pass under
`cargo test -p aldabra-dao --features escrow_wip escrow::`.

Builders + MCP tools land in follow-up commits on this branch.
2026-05-09 11:32:26 -07:00
Sulkta
03b5efb3b2 audit: cargo fmt + clippy --fix across workspace + retract_votes cooldown bug fix
Surfaced by Track #38 code audit (2026-05-09):

1. cargo fmt --all: 217 formatting diffs across 35 files. Pure
   whitespace; no semantic changes.

2. cargo clippy --fix: 30 warnings -> 10. Auto-applied:
   - useless format!() (3 sites in builder/proposal_*.rs)
   - needless_borrow_for_generic_args (4 sites)
   - cloned_ref_to_slice_refs (1 site, builder/proposal_cosign.rs)
   - derivable_impls (1 site, dao/config.rs)
   - unused imports/variables (3 sites)

   Remaining 10 warnings are non-trivial (too_many_arguments on a
   constructor at 8 args, FromStr trait shadow, doc_lazy_continuation
   on a few comment blocks). Filed as tech-debt; no action this pass.

3. cargo audit: 0 vulnerabilities. 2 unmaintained advisories on
   transitive deps:
   - paste 1.0.15 (RUSTSEC-2024-0436) via rmcp + pallas-traverse
   - proc-macro-error 1.0.4 (RUSTSEC-2024-0370) via age->i18n-embed-fl
   Both upstream; tracked but no action needed locally.

4. Test failure surfaced: builder::proposal_retract_votes::tests::
   voting_ready_in_window_subtracts_vote_weight failed — cooldown
   check was applied unconditionally for RemoveVoterLockOnly mode,
   blocking the legitimate 'retract during voting window' path
   where the proposal datum mutates (vote weight subtraction). Per
   Agora's premoveLocks rule, cooldown only applies when retracting
   AFTER voting closed but BEFORE Finished — not during the active
   voting window. Fixed by gating cooldown on
   '!proposal_datum_will_change' so the in-window retract path
   bypasses cooldown the same way RemoveAllLocks does.

   Test: 87/87 aldabra-dao lib tests pass post-fix (was 86/87).
2026-05-09 10:27:48 -07:00
Sulkta
e98be0749f fix(dao,mcp): tie vote tx TTL to validity_upper_slot so Voted.posix_time matches chain's reconstructed validRange.upperBound
Prior to this fix, proposal_vote.rs:486 set the tx TTL to
`tip_slot + VALIDITY_RANGE_SLOTS` (the unclamped default) while the
new stake output's Voted lock embedded `posix_time = validity_upper_ms`
which the MCP layer at tools.rs:3197 may have CLAMPED to
`voting_end_slot` to keep the validity range inside the voting window.
The TX TTL slot and the slot underlying validity_upper_ms then
diverged whenever the clamp fired.

The chain reconstructs txInfo.validRange.upperBound from the TX TTL.
Agora's ppermitVote synthesizes the expected Voted lock with
`posix_time = upperBound` and ponlyLocksUpdated compares it to our
output's lock. With a slot mismatch the lock posix_time differs by
(default - voting_end) seconds — for a 1799-slot default and a small
voting window remainder, this is hundreds-to-thousands of seconds.
The mismatch surfaces as a silent UPLC error in the stake validator
with no preceding ptrace, exactly matching the
'validator crashed / exited prematurely' chain rejections we've been
chasing for two days.

Verified hypothesis against working Clarity vote
4f2fac985a08db2349ef2a650bb66ca6cd42fab1ecc5976bb673687666922503:
TTL slot 130276129 → posix_ms 1721842420000, Voted.posix_time
1721842420000 — exact match (diff 0 ms). Our failing prop #5 vote
4f2fac98... had TTL.posix_ms = 1778296041000 vs Voted.posix_time
1778294743000 = 1298s mismatch.

Fix: introduce explicit `validity_upper_slot` field on
ProposalVoteArgs alongside `validity_upper_ms`. Caller sets BOTH
from the same source (MCP layer already had this slot in scope at
the clamp site). The builder's TTL now uses validity_upper_slot
(so the chain computes the same upperBound as our datum embedded).

Other builders (cosign / advance / retract_votes) don't write a
datum field that depends on slot↔ms conversion, so they're not
affected by this bug.

Test fixture updated to derive validity_upper_slot from
validity_upper_ms via the mainnet shelley-zero constants.
2026-05-09 06:09:48 -07:00
Sulkta
cbf69161ab feat(dao,mcp): proposal_retract_votes builder + dao_stake_retract_votes_unsigned
Closes the destroy-with-locks gap: a stake that voted/created/cosigned a
proposal can now drop those locks once the proposal resolves (or once
voter cooldown elapses), letting it become destroyable via
dao_stake_destroy_unsigned.

Tx shape mirrors proposal_vote: stake input (RetractVotes redeemer) +
proposal input (UnlockStake redeemer) + wallet funding, two reference
scripts, two outputs. Mode auto-derived from proposal status:
  - Finished       → RemoveAllLocks  (drop Created/Cosigned/Voted for id)
  - any other      → RemoveVoterLockOnly (drop only past-cooldown Voted)

When proposal is VotingReady AND tx-validity sits inside the voting
window, also subtracts stake.staked_amount from proposal.votes[voted_tag]
(matches the proposal validator's `shouldUpdateVotes` path at
Agora/Proposal/Scripts.hs:606).

Pre-flights every validator check: ownership/delegation, at least one
lock for proposal_id (proposal validator's pisIrrelevant), Voted-lock
cooldown (Stake/Redeemers.hs:354), and rejects the VotingReady-no-Voted
edge case where the validator's "Votes changed" assertion would fail.

Eight unit tests covering: Finished-drops-all, VotingReady-window-
subtracts, no-locks-for-proposal rejection, cooldown-not-elapsed
rejection, no-Voted-in-window rejection, voter-not-owner-or-delegate
rejection, Locked-after-window-drops-Voted-only.

Validator is from existing `registry.example.com:5000/aldabra/mcp@425c981` with
StakeRedeemer::RetractVotes and ProposalRedeemer::UnlockStake already
landed; this just wires the builder + MCP tool.
2026-05-08 13:24:24 -07:00
Sulkta
892e4b0ae1 fix(dao,mcp): clamp proposal_advance validity range to fit phase window
The advance builder previously hard-coded the validity range to
[tip_slot, tip_slot + VALIDITY_RANGE_SLOTS=1799]. For early
Draft→VotingReady advance with the wide 1799-slot range, the upper
bound shoots ~30min past starting_time — way past drafting_end on
any DAO with windows narrower than 30 min, including Sulkta-shape
30-min DAOs whose drafting period happens to start partly elapsed.
Validator's getTimingRelation rejects straddles and the MCP layer
then errored 'tx validity range straddles drafting period
boundary'.

Two-part fix:

1. ProposalAdvanceArgs gains optional valid_from_slot_override +
   invalid_from_slot_override fields. None preserves legacy behavior;
   Some(...) lets the MCP layer dictate a clamped range. Builder
   defends against degenerate ranges (invalid_from <= valid_from).

2. MCP-side dao_proposal_advance_unsigned, when transition is
   DraftToVotingReady or VotingReadyToLocked and the natural [tip,
   tip+1799] would overshoot the period end, clamps invalid_from
   to the period_end slot. Refuses if remaining slots < 5 (chain
   has no room to include the tx) and prompts the caller to wait
   for the failed-too-late path instead.

Caught 2026-05-08 trying to drive preprod_test2 proposal #0
through the proper Draft→VotingReady arc — chain time was ~3 min
past starting_time, so 1799-slot range overshot drafting_end by
~27 min. Same code path now clamps to the remaining ~27 min of
drafting period.

Closes audit finding H-2 for the DraftToVotingReady and
VotingReadyToLocked transitions. Cosign + vote builders also have
H-2 (raw tip_slot for validity_from); those are deferred.
2026-05-08 11:09:26 -07:00
Sulkta
48679883ec feat(mcp,chain,dao): support Koios paid-tier bearer via ALDABRA_KOIOS_BEARER env
Adds optional Authorization: Bearer <token> on every Koios request,
sourced from ALDABRA_KOIOS_BEARER env var only — never from the
on-disk config.toml, never from CLI args, never hardcoded. Bearers
are credentials and the on-disk config dir gets routinely backed up;
keeping them env-only guarantees rotations don't leak into snapshots.

Wired through three Koios clients:
- aldabra-chain::KoiosClient — new with_timeout_and_bearer ctor;
  legacy new() / with_timeout() route through it with bearer=None.
- aldabra-dao::KoiosDaoReader — new with_bearer ctor; ditto.
- aldabra-dao::KoiosDiscoveryClient — new with_bearer ctor; ditto.

Bearer is set as a default header on the reqwest client builder so
every request inherits it without per-call boilerplate.
HeaderValue::set_sensitive(true) prevents the value from showing
in reqwest's debug-format output.

Config wiring (aldabra-mcp::config::Config):
- New koios_bearer: Option<String> field. Loaded ONLY from
  ALDABRA_KOIOS_BEARER env var; absent or empty-string means None.
- Startup tracing logs koios_bearer_set: bool — never the value.

WalletInner caches the bearer alongside the koios_base so the
on-demand KoiosDiscoveryClient (constructed inside
dao_discover_scripts) inherits paid-tier auth too.

Motivation: 2026-05-08 preprod_test2 bringup tripped Koios free-tier
daily quota (5240 req/day, 'Exceeded Tier Limit') mid-deploy. Sulkta
provided a paid-tier JWT (Aldabra project, exp 2026-06-26). Wiring
via env var lets the operator (systemd EnvironmentFile, docker run
-e, or k8s Secret) inject it without touching code or config files.
2026-05-08 10:19:06 -07:00
Sulkta
adc2154437 fix(dao): center starting_time in proposal_create validity window
For tiny-window test DAOs (preprod_test: 30s), the prior anchor
(valid_from = starting_time, invalid_from = starting_time + 30)
gave zero past-side slack. With koios block_time vs real chain
clock skewing ±60s on the public endpoint, hitting that window
is essentially a coin flip — the tx submits but never confirms
because the next block lands after invalid_from.

Centering keeps the validator-required width unchanged but moves
valid_from to starting_time - 15, so the chain now has 15s of
past-side slack to land the tx in a block. Same width, same
in-script time check (starting_time still ∈ [valid_from, invalid_from)),
better landing odds.

Sulkta-shape DAOs (1800s windows) are unaffected: 900s of slack
each side is plenty either way.
2026-05-07 20:19:27 -07:00
Sulkta
3b445fae89 fix(dao): prepend (cons) Created lock instead of append in proposal_create
Agora's stake validator (ppermitVote) enforces output_locks =
pcons NEW_LOCK old_locks (head-cons, not append). proposal_create
was using Vec::push which appends, so when the stake had any
pre-existing locks the output lock order didn't match what the
validator expected and the chain rejected with CekError on the
stake validator (5178-byte script, hash 57d6b17f...).

The bug went undetected on 2026-05-07 because that day's first
proposal_create ran on a stake with locked_by = [], where push
and prepend produce the same single-element vector. Today's
proposal #1 attempt — stake already holding a Created lock for #0 —
flushed it out.

Mirror the prepend pattern that proposal_cosign and proposal_vote
already use: build new_locks with the new lock at index 0, then
extend with the old locks.
2026-05-07 19:43:10 -07:00
Sulkta
82613bb188 fix(dao): wire V2 cost model into advance/cosign/vote/stake_destroy
Same trap that hit proposal_create yesterday: every spending tx that
witnesses a PlutusV2 script (Agora's proposal_validator, stake_validator,
proposalSt policy on burn) needs language_view in the tx body so the
chain-side script_data_hash matches the off-chain one. Without this the
chain rejects with PPViewHashesDontMatch.

proposal_create got the fix in 6b8d2ccc. The other four builders shipped
without it, so dao_proposal_advance_unsigned (Draft → Finished on
preprod_test #0) hit PPViewHashesDontMatch on submit. Mirror the same
language_view + ScriptKind::PlutusV2 + PLUTUS_V2_COST_MODEL_PREPROD
wiring into the remaining four staging chains.
2026-05-07 18:56:57 -07:00
Sulkta
7b4215423a fix(dao): use PermitVote (not DepositWithdraw) for stake spend on proposal_create
Stake validator's DepositWithdraw branch requires locked_by to stay
EMPTY. proposal_create wants to ADD a Created lock for the new
proposal — that's PermitVote's job, not DepositWithdraw's.

Caught by base64-decoding the CekError's failing-script header on
preprod_test today: 0x59 0x14 0x37 = bytes(5175) ⇒ 5178-byte script
= stake validator (57d6b17f), not the governor we'd been suspecting.

This is the audit C-2b fix that landed late.
2026-05-07 18:09:20 -07:00
Sulkta
a1c6ab3f08 fix(dao): anchor proposal-create validity range on starting_time_slot
Public Koios's tip endpoint can lag the actual chain by 100+ slots.
Under a 29-slot governor window that lag pushes invalid_after into
the past before the tx ever reaches a node — every retry hits
OutsideValidityIntervalUTxO no matter how fast we sign+submit.

Now: caller passes starting_time_slot derived from starting_time_ms
via the network's shelley constants; builder uses it as valid_from.
Caller can shift starting_time_ms slightly into the future to
compensate for MCP roundtrip latency. The on-chain
'pvalidateProposalStartingTime' is still satisfied because
starting_time_slot ∈ validRange by construction.
2026-05-07 17:31:45 -07:00
Sulkta
591f2f0861 fix(dao): clamp proposal-create validity range to per-DAO max_width
VALIDITY_RANGE_SLOTS const was hardcoded to 1799 (Sulkta's 30min budget
minus 1 slot). For tiny test DAOs (preprod_test: 30s) this overshoots
the governor's create_proposal_time_range_max_width and the validator
rejects with CekError on submit. Now: derive max width from
GovernorDatum.create_proposal_time_range_max_width / 1000 - 1, capped
at VALIDITY_RANGE_SLOTS for safety.
2026-05-07 17:12:10 -07:00
Sulkta
6b8d2cccbc fix(dao): wire V2 cost model into proposal_create staging
Without staging.language_view(), pallas does not compute
script_data_hash. Chain rejects the tx with PPViewHashesDontMatch.
Same trap that plutus_mint hit on 2026-05-07 — same fix here.

Caught attempting first dao_proposal_create_unsigned on preprod_test
DAO 2026-05-07 PM after deploying governor + proposal validator ref
UTxOs via the new file-path workaround for the MCP large-string bug.
2026-05-07 16:57:05 -07:00
Sulkta
71dae49c0d Revert "feat(dao): Phase 4c-bis-1 + 4c-bis-2 — typed EffectsMap + GAT policy config"
This reverts commit d8dca8d119.
2026-05-06 10:39:51 -07:00
Sulkta
d8dca8d119 feat(dao): Phase 4c-bis-1 + 4c-bis-2 — typed EffectsMap + GAT policy config
## Type port effects_raw → EffectsMap (4c-bis-1)

Replace ProposalDatum.effects_raw: PlutusData with typed
effects: EffectsMap. Required precondition for the upcoming
proposal_mint_gats builder — every downstream piece reads typed
effects, not opaque bytes.

- ProposalEffectMetadata { datum_hash: 32 bytes, script_hash: Option<28 bytes> }
  ProductIsData → CBOR Array. Field order matches Agora.Proposal.hs:296.
  Maybe ScriptHash → Constr 0 [bytes]/Constr 1 [].
- EffectsMap: Vec<(ResultTag i64, Vec<(ScriptHash, ProposalEffectMetadata)>)>.
  Encoded as nested PlutusData::Map. Keys preserved in insertion
  order (Plutus map equality is set-like, but validator builds the
  expected datum in same order so we stay consistent).
- EffectsMap::info_only(&[0, 1]) helper for the proposal_create case
  (every result_tag → empty inner map). Replaces the hand-rolled
  PlutusData::Map.
- has_neutral_effect() + keys() helpers for validator preflight checks.

Live-decode test (decodes_sulkta_live_proposal_zero) tightened:
Sulkta #0 is NOT pure InfoOnly — tag 1 has a real effect targeting
script hash 92b7..96f with datum_hash 046dff..e83c (no auth-script
wrapper). Tag 0 is empty so phasNeutralEffect still passes. Test
asserts the full typed shape now + round-trips via decode↔encode.

All 4 builders' fixtures updated: effects_raw: constr(0, vec![]) →
effects: EffectsMap::info_only(&[0, 1]). Unused constr/PlutusData/
KeyValuePairs imports pruned.

## DaoConfig GAT policy fields (4c-bis-2)

- DaoConfig.gat_policy: Option<String> (56 hex)
- ScriptRefs.gat_policy_ref: Option<String> (txhash#index)
- DaoConfig::validate now checks gat_policy + stake_st_policy +
  proposal_st_policy are 56 hex chars when set
- All DaoConfig fixtures updated with gat_policy: None
- DaoRegisterArgs gains gat_policy + gat_policy_ref optional fields
- dao_show output includes the new fields automatically (serde)

Sulkta-specific note: gat_policy hash isn't observable on chain
yet (no MintGATs tx has fired). Hand-populate from MLabs deployment
record when ready, or compute from the deployed governor's CBOR
parameters.
2026-05-06 10:13:11 -07:00
Sulkta
6406a170b0 test(dao): live-stake round-trip checks decoded struct, not bytes
The first cut asserted byte-exact CBOR round-trip, but pallas-codec
emits def-arrays (`81`) while chain CBOR uses indef (`9f...ff`).
Both are Plutus-structurally-equal — validator's `==` accepts
either — but Vec<u8> equality doesn't. Switch to assert
`decode(reencode(decode(cbor)))` equals `decode(cbor)` instead.
That's the actual validator-relevant invariant: typed fields
preserved, no silent drift.
2026-05-06 08:53:04 -07:00
Sulkta
6f935b58b2 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).
2026-05-06 08:38:15 -07:00
Sulkta
e4830ef250 feat(dao): proposal_advance state machine + stake_destroy + MCP tools
Phase 4c + 4d. Closes the DAO write-path arc (excluding GAT minting,
which is Phase 4c-bis since Sulkta has never executed a proposal).

## proposal_advance (Phase 4c)

State-machine builder with 5 transitions:
- Draft → VotingReady (cosigner threshold met, all cosigner stakes
  ref'd as txInfo.referenceInputs, sum staked_amount ≥ to_voting)
- Draft → Finished (drafting period elapsed without enough cosigners)
- VotingReady → Locked (winner outcome exists with votes ≥ execute,
  no tie)
- VotingReady → Finished (locking period elapsed without winner)
- Locked → Finished (executing period elapsed; for InfoOnly proposals)

Validator (PAdvanceProposal in Proposal/Scripts.hs:657) requires
output proposal datum equals input with ONLY status mutated. Builder
mirrors exactly. Per-transition preflights match validator gates.

Cosigner stake refs go in as txInfo.referenceInputs (not regular
inputs) per witnessStakes pattern (Proposal/Scripts.hs:366) — sum
of staked_amount is computed from the ref-input set.

GAT-minting Locked→Finished path (effected proposals) deferred to
4c-bis. The pmintGATs governor redeemer is a separate tx that fires
ONLY when the executing period is in window AND the winner outcome
has effects to mint GATs for. Sulkta's first proposal was InfoOnly
so this path never exercised on chain yet.

11 unit tests covering every transition + every preflight reject.

## stake_destroy (Phase 4d)

Burns StakeST token + returns gov-tokens to owner. From
Stake/Redeemers.hs pdestroy (~L432): owner signs (no delegatees),
all locks empty, no stake output at stakes_addr. From stakePolicy
burn branch (~L161): burntST quantity = -spentST.

Tx shape: spend stake (Destroy redeemer) + maybe a funding utxo +
collateral; mint -1 StakeST; one wallet output carrying gov-tokens
+ (stake.lovelace + funding - fee). Funding optional — stake's own
lovelace usually covers fees.

4 unit tests including the funding-optional path.

## MCP tools

dao_proposal_advance_unsigned auto-picks the right transition from
proposal status + chain tip vs window boundaries. Mainnet-only gate.
Fetches cosigner stake refs by matching owner pkh against
proposal.cosigners.

dao_stake_destroy_unsigned fetches the wallet's stake (via owner
pkh match), pulls StakeST asset name from chain, burns it.
2026-05-06 07:00:48 -07:00
Sulkta
a806329baa feat(dao): proposal_cosign builder + dao_proposal_cosign_unsigned tool
Phase 4b. Cosign extends a Draft proposal's cosigners list — the
multi-stake bridge for clearing to_voting threshold when a single
stake doesn't have enough TRP. Validator (PCosign branch in
Proposal/Scripts.hs:433) requires:

- Status == Draft
- Exactly one stake input (ptryFromSingleton)
- New cosigner = stake.owner (delegatees rejected)
- Cosigner inserted into list via pinsertUniqueBy (sorted, no dupes)
- len(cosigners) ≤ max_cosigners (DaoConfig.max_cosigners)
- stake.staked_amount ≥ thresholds.cosign

Stake-side (ppermitVote PCosign branch): owner signs (not delegatee),
single stake input, new lock = ProposalLock { proposal_id, Cosigned }
prepended via paddNewLock = pcons.

Insertion order mirrors Plutarch's pfromOrdBy-derived Credential Ord:
variant index first (PubKey=0 < Script=1), then 28-byte hash lex.
`insert_unique_sorted` test-covered for low/mid/high positions + the
PubKey-before-Script invariant.

Also extract pull_wallet_utxos free function in tools.rs — shared
between the (future) refactor of create/vote and immediately by
cosign. Inline duplication in create/vote left as a future cleanup.

11 unit tests on the builder. Tool args: dao? + proposal_id +
fee_lovelace.
2026-05-06 06:51:53 -07:00
Sulkta
cc69e0df21 refactor(dao): wire KoiosDaoReader::list_proposals + use it from vote tool
The first attempt's vote MCP tool inlined a Koios address_info pull
helper in tools.rs that needed reqwest + pallas_codec + pallas_primitives
as direct deps on aldabra-mcp — which it doesn't have. Compile failed.

Cleaner: move the work into the dao crate where those deps already live.

- ProposalUtxo gains `lovelace` + `proposal_st_asset_name_hex`. The
  vote builder needs both to construct the new proposal output.
- KoiosDaoReader::list_proposals (was stubbed) now reads cfg.proposal_addr,
  decodes every UTxO's inline datum to ProposalDatum, and matches the
  ProposalST asset name against cfg.proposal_st_policy when set, falling
  back to the first asset on the utxo when not (Sulkta convention is one
  ProposalST + nothing else).
- KoiosAsset.asset_name no longer #[allow(dead_code)] — it's read now.
- tools.rs::dao_proposal_vote_unsigned switches to dao_reader.list_proposals
  + drops the inline pull helper. ~150 LOC simpler.
2026-05-06 06:41:52 -07:00
Sulkta
d9f852d8bd feat(dao-mcp): wire dao_proposal_vote_unsigned + slot↔ms helper
- DaoProposalVoteArgs (dao? + proposal_id + result_tag + fee_lovelace)
- mainnet_slot_to_posix_ms: Shelley genesis constants (slot 4_492_800,
  posix 1_596_059_091_000) for converting tip+VALIDITY_RANGE_SLOTS
  into the Voted lock's posix_time field
- pull_proposal_utxos helper: address_info → decode every UTxO's
  inline datum into ProposalDatum, return matching proposal_id by id
  match (`KoiosDaoReader::list_proposals` is still stubbed; this is
  a focused write-path read)
- mainnet-only network gate (preprod/preview slot↔ms is Phase 5)
- get_info instructions text mentions write tools
- drop unused pallas_addresses::Address + pallas_txbuilder::ExUnits
  imports surfaced by clippy
2026-05-06 06:37:31 -07:00
Sulkta
59cdd92b57 feat(dao): proposal_vote.rs builder — Phase 3 unsigned tx
Mirrors proposal_create's shape: 3 inputs (stake script + proposal
script + funding wallet), 2 reference inputs (stake validator +
proposal validator), 2 outputs (mutated stake + mutated proposal +
maybe change), 2 plutus spends (PermitVote + Vote tag), no mints.

Pre-flight matches every Plutarch validator check from
Agora/Proposal/Scripts.hs PVote (~L484) + Stake/Redeemers.hs
ppermitVote (~L196):

- voter pkh is owner OR delegatee
- proposal status == VotingReady
- stake doesn't already have Voted lock for this proposal_id
- stake.staked_amount >= proposal.thresholds.vote (single-stake v1)
- result_tag is in proposal.votes keys (== effects keys)
- validity upper bound inside [starting_time + draft_time,
  starting_time + draft_time + voting_time]

New stake datum prepends the Voted lock per paddNewLock = pcons.
New proposal datum increments votes[result_tag] by stake amount;
all other fields preserved bit-exact since validator does record `==`.

Voted.posix_time = caller-supplied validity_upper_ms — matches
PFullyBoundedTimeRange _ upperBound the validator extracts. Caller
(MCP tool) computes ms-from-slot via mainnet Shelley genesis.

9 unit tests covering happy path + every preflight reject + delegated
voter accepted.
2026-05-06 06:31:22 -07:00
Sulkta
8d71b43e48 chore(dao): drop unused imports — ScriptRefs to test scope, StakeDatum gone from tools.rs 2026-05-05 20:58:03 -07:00
Sulkta
90a39a4df2 test(dao): update proposal_create test fixture for new args (stake_in + tip_slot + GST) 2026-05-05 20:56:09 -07:00
Sulkta
e0bceae908 fix(dao): audit C-1 + C-2 + C-3 — informed by reference tx 7c8db1432a07 2026-05-05 20:55:20 -07:00
Sulkta
6955377c61 fix(dao): audit H-2 + H-5 + H-6 (per internal notes)
H-2: drop ExUnits to 5M/2G for spend, 2M/1G for mint
  Was 14M/10G each = per-tx Conway cap. With 3 plutus contracts running
  (governor spend + stake spend + ProposalST mint), total claim 42M/30G
  exceeds per-tx limit and node rejects pre-phase-2.

H-5: propagate malformed wallet asset keys instead of silently dropping
  Previous filter_map silently dropped any key < 56 chars. Could let a
  corrupt Koios response burn assets on submit. Now returns explicit
  Err with the offending UTxO + key.

H-6: tighten StakeST detection to asset_name == stake_validator_hash
  Per Stake/Scripts.hs:188-190 (pscriptHashToTokenName), StakeST
  asset_name is the stake validator's script hash. Previous code took
  "first non-gov-token asset" which would silently pick a wrong policy
  if a stake UTxO accidentally carried a junk NFT. Regression test
  h6_junk_token_does_not_pollute_stake_st_detection added.

3 of 7 audit punch-list items closed. C-1 + C-2 + C-3 next.
2026-05-05 20:52:22 -07:00
Sulkta
4644fb525f fix(dao): scope as_constr import to test that uses it 2026-05-05 20:14:33 -07:00
Sulkta
69f93339fd feat(dao): dao_discover_scripts MCP tool + Koios discovery client
New `aldabra-dao::discovery` module:
- `DiscoveryClient` trait + `KoiosDiscoveryClient` impl
- `discover_scripts(cfg, client, deployers)` — auto-finds:
  - governor_validator_ref + stake_validator_ref via deployer ref-script search
  - stake_st_policy from any existing stake UTxO (gov-token + non-gov-token asset)
  - stake_st_policy_ref via deployer search
- `apply_discovery(cfg, report)` — merges into DaoConfig (never overwrites)
- `script_hash_from_addr(bech32)` — extract 28-byte script hash from a script address

New MCP tool:
- `dao_discover_scripts { dao?, extra_deployers? }` — runs the audit logic
  against any registered DAO + persists the discovered fields back to the
  DaoConfig. Returns JSON with what was found + a gaps list for things
  v1 can't auto-discover (proposal_addr, proposal_st_policy).

Plus 4 unit tests with stub Koios responses validating the full pipeline:
script-hash extraction, StakeST discovery from stake UTxO assets,
validator ref-utxo matching at deployer, apply_discovery merge semantics.

WalletInner now caches `koios_base` so the discovery client can be
constructed on demand without re-passing the URL through args.
2026-05-05 20:14:13 -07:00
Sulkta
207dcae971 chore(dao): drop unused as_constr import after EnumIsData fix 2026-05-05 19:50:34 -07:00
Sulkta
6a1e97d04c feat(dao-mcp): wire dao_proposal_create_unsigned + drop unused imports 2026-05-05 19:50:17 -07:00
Sulkta
4ec58f49f2 feat(dao): proposal_create.rs skeleton + InfoOnly builder + tests 2026-05-05 19:48:21 -07:00
Sulkta
7316eed248 feat(dao-config): Phase 4 prerequisite fields + ScriptRefs
DaoConfig gains optional fields for Phase 4 (proposal_create) work:
- proposal_addr        — proposal validator address (bech32)
- stake_st_policy      — StakeST minting policy id (56 hex)
- proposal_st_policy   — ProposalST minting policy id (56 hex)
- script_refs          — cached reference UTxO refs for each Agora script
                         (governor / stake / proposal / treasury validators
                         + stake_st / proposal_st minting policies)

All fields optional with serde defaults so existing configs keep loading.
Will be populated by upcoming `dao_discover_scripts` MCP tool that audits
on-chain state under a known governor_addr.

Test fixture also corrected: stakes_addr now uses Sulkta's real per-DAO
parameterized stake-validator address (`addr1w8msu7p...`) instead of the
shared MLabs deployer (`addr1w9gexmeunzsy...`) — matches audit findings.

aldabra-mcp dao_register tool initializes new optionals to None so
DaoConfig construction stays explicit.
2026-05-05 15:42:59 -07:00
Sulkta
0ef4730dbb fix(dao): correct Proposal #0 starting_time assertion (2026-04-21 not 2026-05-03) 2026-05-05 15:41:32 -07:00
Sulkta
bcfb98db1e fix(dao): EnumIsData → plain Integer, not Constr i []
Verified against Sulkta's live Proposal #0 datum 2026-05-05:
status field is bare BigInt(3), not Constr 3 []. Plutarch's
EnumIsData derive emits Integer-as-index in this Agora version.

Affected:
- ProposalStatus.{to,from}_plutus_data
- GovernorRedeemer.to_plutus_data (consistency; no on-chain
  governor-redeemer evidence yet, but same EnumIsData derive)

ProposalDatum.to_plutus_data signature updated for the new fallible
ProposalStatus encoding (now returns DaoResult).

Added regression test `decodes_sulkta_live_proposal_zero` that decodes
Proposal #0's actual on-chain datum hex and asserts:
  proposal_id=0, status=Finished, cosigners=[Sulkta's pkh],
  thresholds=20/100/100/1/1, votes={0:0, 1:0} (zero votes ever cast),
  starting_time=1772666551575ms.

Closes audit findings 1 + 2 from internal notes.
2026-05-05 15:41:03 -07:00
Sulkta
0f5676dda7 chore(dao): drop unused as_constr import from governor.rs top-level 2026-05-05 13:46:50 -07:00
Sulkta
1a96b30880 fix(dao): ProductIsData encodes as CBOR Array, not Constr 0
The Plutarch `ProductIsData` derive (used by every record datum in
Agora) emits a CBOR list of fields, NOT the generic Constr 0
encoding I assumed during Phase 0. Verified by decoding Sulkta's
live governor UTxO datum: outer bytes start `9f 9f` (indef array of
indef arrays), not `d8 79` (Constr tag 121).

Affected types:
- StakeDatum, ProposalLock        (was Constr 0, now Array)
- ProposalDatum, ProposalThresholds, ProposalTimingConfig
- GovernorDatum

Sum types untouched — they keep Constr-encoding (makeIsDataIndexed
or EnumIsData both produce Constr i [...]):
- Credential, ProposalAction, StakeRedeemer, ProposalRedeemer,
  GovernorRedeemer, ProposalStatus

New helpers in plutus_data.rs:
- `product(fields)` — emit indefinite-length CBOR Array
- `as_product(pd)` — decode (alias for as_array, named for intent)

Added end-to-end validation test `decodes_sulkta_live_governor_datum`
that decodes the real on-chain datum hex from Sulkta's governor UTxO
(7c8db14...221c47#1) and asserts the parsed struct matches README
parameters: thresholds [20/100/100/1/1], 7d draft, 7d vote, 48h lock,
24h exec, 30min ranges, max 20 proposals per stake.
2026-05-05 13:46:21 -07:00
Sulkta
5c41dbda86 fix(dao): build errors in plutus_data — or-pattern parens, Int→i128, BoundedBytes AsRef ambiguity 2026-05-05 13:42:33 -07:00