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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
⚠ 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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
## 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.
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.
## 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).
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.
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.
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.
- 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
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.
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.
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.
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.
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.