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.
Two CRIT findings from the 2026-05-12 Opus audit. Both are
mainnet-blocking against the aldabra-mainnet container.
CRIT-1 — cap-bypass via unsigned-build → sign_partial → submit chain.
Previously `wallet_send` / `wallet_mint` / `wallet_mint_cip68_nft` /
`wallet_script_spend` enforced `max_send_lovelace`, but the unsigned-
build tools + `wallet_sign_partial` + `wallet_submit_signed_tx` did
not. A prompt-injection that walked the cold-signer chain could drain
the wallet past the cap with zero policy enforcement.
Fix:
- `wallet_send_unsigned` / `wallet_mint_unsigned` /
`wallet_plutus_mint_unsigned` now enforce the cap on the user-
supplied destination lovelace, mirroring their signed equivalents.
All three gain a `force: bool` arg with `#[serde(default)]`.
- `wallet_sign_partial` and `wallet_submit_signed_tx` decode the
Conway tx CBOR, sum lovelace across every output whose address is
NOT this wallet's own primary address, and enforce the cap on that
total. Both gain `force: bool`. The chokepoint covers cold-signed
multi-sig flows and any hand-built CBOR the daemon would otherwise
blindly sign or submit.
- New free fn `sum_non_self_lovelace` is the unit-testable core of
the chokepoint logic; `enforce_cap_on_cbor` wraps it.
- The sum uses `try_fold` + `checked_add` (NOT `.sum::<u64>()`) so a
crafted CBOR that overflows `u64::MAX` fails the check instead of
wrapping silently in release builds.
CRIT-2 — path traversal via `reference_script_path` and
`policy_cbor_path`. Previously the tools called `std::fs::read_to_
string(p)` on any path the LLM passed. The MCP daemon runs as the
same user that owns `$ALDABRA_DATA/mnemonic.age` /
`$ALDABRA_DATA/root-xprv.age`. Decode-error messages included the
hex_decode position offset — a small but real information leak about
non-hex file structure.
Fix:
- New `Config::safe_reads_root` field (default `$ALDABRA_DATA/scripts/`,
override via `ALDABRA_SAFE_READS_ROOT` env or TOML).
- New `assert_inside_sandbox` helper canonicalize()s both the root and
the user-supplied path, then enforces `starts_with`. Rejects
outside-root paths, `..`-traversal, and nonexistent paths with
generic messages.
- Hardlink-rejection: post-canonicalize, stat the file and refuse if
`nlink > 1`. `canonicalize` resolves symlinks but NOT hardlinks (a
hardlink IS the file — same inode, different directory entry), so
without this check an attacker with daemon-uid write access could
plant a hardlink to the encrypted key blob inside the sandbox and
exfiltrate bytes through the read path.
- `resolve_ref_script_bytes` + `resolve_policy_cbor_bytes` + the
`resolve_validator_required` wrapper used by all 5 escrow spend
tools take `&Path` and route through the sandbox.
- Error messages on hex_decode failures no longer carry the path
string or byte-offset position — return a constant "contents are
not valid hex" instead.
- `main.rs` creates the sandbox root with 0o700 perms at startup if
missing. chmod errors are surfaced (not swallowed) so a broken
filesystem doesn't silently fall back to umask 0o755.
- README documents the new `ALDABRA_SAFE_READS_ROOT` env var alongside
`ALDABRA_MAX_SEND_LOVELACE` (also previously undocumented).
Tests (243 → 253, +10):
- 5 sandbox tests: accept-inside, reject-outside, reject-dotdot,
reject-nonexistent, reject-hardlink.
- 1 non-hex regression: constant message (no byte-offset leak).
- 3 cap tests: self-send → 0 non-self total, outbound counts,
overflow → Err (regression for the prompt-injection `u64::MAX`
wraparound attempt).
- 1 garbage-CBOR test: clean error.
No new clippy warnings, no new fmt drift, `cargo audit` unchanged
(0 CVEs, 2 transitive unmaintained warnings).
Adversarial review of the first draft (3 Opus reviewers) caught the
u64 overflow, the hardlink bypass, and the swallowed chmod error.
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
(`audit-sulkta-agora-2026-05-05.md`, `audits/2026-05-09-escrow-spec.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 (audits/2026-05-09-escrow-
internal-audit.md, audits/2026-05-09-escrow-e2e.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.
- audits/2026-05-09-escrow-e2e.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.
- audits/2026-05-09-escrow-internal-audit.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.
- README.md: drop "first Sulkta Rust project — workout for crafting-
table's Rust toolchain" paragraph + the `crafting-table build aldabra`
recipe. Both reference non-public Sulkta-internal infrastructure.
- Dockerfile: drop "Built nightly on Lucy (see lucy-infra/scripts/
nightly-builds.sh)" comment + the `lucy-registry:5000/aldabra/mcp`
internal image-name advertisement.
- Cargo.toml: drop the comment block referencing the deleted
`docs/internal-build-rewrites.md` + `crafting-table + Lucy + dev
hosts` Sulkta-internal-builds note. The patch block stands on its
own.
Removed mentions of Rackham + Sulkta-runs-its-own-Koios claims from
README + module doc-comments + Cargo.toml descriptions. aldabra works
against any Koios endpoint — public api.koios.rest, preprod/preview,
or operator-self-hosted — so the docs now reflect that capability
neutrally instead of advertising our internal infra.
GitHub is canonical for aldabra now (per 2026-05-10 architecture call —
Gitea is a pull-mirror cache, not a forge we publish to). Build process
fetches pallas direct from github.com, no rewrite needed.
- Dockerfile: drop the `--mount=type=secret` git_credentials dance + the
url.insteadOf rewrite. cargo fetches pallas straight from
github.com/Sulkta-Coop/pallas at the locked SHA. No secret needed.
- docs/internal-build-rewrites.md: removed. The rewrite was the entire
reason for the doc, and the rewrite is gone.
Internal builds (Lucy / crafting-table / dev hosts) still hit
github.com for pallas, same as external builds. One extra WAN hop per
crate, but consistent everywhere and no environment-specific config.
Cargo.toml + Cargo.lock now point at https://github.com/Sulkta-Coop/pallas
for the pallas-fork patch entries. External clones from either public
mirror (github.com/Sulkta-Coop or gitlab.com/sulkta) build out of the box
— no LAN access needed.
Sulkta-internal builds short-circuit to LAN gitea via a `git config
url.X.insteadOf` rewrite on each host. Symmetric: covers both github and
gitlab → gitea. Same locked SHA either way; routing is environment-level,
not source-of-truth. See docs/internal-build-rewrites.md.
Dockerfile build-time rewrite also flipped to take public-URL inputs;
uses `gitea.sulkta.lan` instead of the bare LAN IP.
Per Cobb 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 Cobb'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.
Settle path (4 txs):
- open a878900c09022381f332ca2cea1b4624202ebdbd6f3a83fd9de07475bb98bd6b
- bob deposit ef8910101e88b63abb28ec9b511616e3465075b8d34d5eeb9703efe1876a62bf
- agree bbfd57c3acb68ddb76d6b92c0dbe8ba9cb21ca88ad6370d19f00822c3b69d655
- settle 4b52312ce264dba74a6fde6c2ccb597696022c8919470f23670e2746db10d1ff
agreed_at_ms=1778381375000 + lock_period_ms=1_800_000 = 1778383175000
earliest_settle. Tip at settle submit: 1778383733000 (558s past).
recipient (bob) receives 10 ADA at enterprise address. Settle requires
no party signer — preprod drove as fee-payer only.
Validator's Settle branch executed cleanly: state==Agreed check,
strict-> time gate, recipient payout via value_geq_value(paid, in_value).
MED-2/3 fix (slot-derived validity_lower_ms) held a second time under
different timing. Total escrow value cycled through validator across all
three E2E paths: 5+10+10 = 25 tADA, 9 successful txs across 6 distinct
validator branches (open as no-script, deposit, agree, veto, settle,
refund). Zero failed txs, zero collateral burns.
Code surface complete. Next: drop escrow_wip flag (task #48) per Cobb's
2026-05-09 directive — replace compile-time gate with runtime
"use at own risk" note when an agent calls escrow_open.
5 of 6 builders proven on chain on preprod_test2 against the
post-audit validator (a8081acef26935d9b5f44b92052178e17301b6d6e6808c91c5b56f5d).
Veto path (4 builders):
- open 0972963e8eb46597b37dfa7e1fa15c97fea4e39d55b29c54f723ba1b155bc7cb
- deposit 0519cb7dbb8c2c1632db5baee90d5440d2905c151f910a085c78d9050c2d6175 ← first Plutus V3 spend on chain
- agree f6664079d96c453e1a6333c33c65744984953c474e57cdb331e78ad3e5429cc7
- veto 14a4be9f233ab02e518356e5963d30517986984f6fd3520151c924057db9c661 ← HIGH-2 fix proven
Refund-timeout path (5th builder):
- open(short) 0669ef61b8695bedcb4eb4c38fda2bd66c68c9384b57839329a01a745db75305
- refund 41590ac6ed069586e650da58858436cfe6be51a865069a7a4b40f795dfcdbff9 ← strict-> time gate proven, MED-2/3 fix held
Settle deferred — needs 30-min lock_period_ms wait. Builder + validator
branch unit-tested (5/5 tests pass), on-chain validation in next session.
Both HIGH validator fixes from the 2026-05-09 internal audit confirmed
running on chain. Multi-party signing flow + slot↔ms boundary math
both held up under real preprod chain race conditions.
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
audits/2026-05-09-escrow-internal-audit.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).
schemars derives an empty (no-type) JSON Schema for Option<serde_json::Value>
and serde_json::Value fields. Claude Code's MCP client interprets schema-
without-type as 'string-encoded' and JSON-stringifies the user's {...}
before sending — at which point the server-side validation
`value.is_object()` returns false and the tool errors with
'CIP-25 metadata must be a JSON object' / 'CIP-68 metadata must be a
JSON object'.
Surfaced during Track #37 E2E test (2026-05-09): wallet_mint and
wallet_mint_cip68_nft both rejected metadata-as-object args from
Claude Code while the SAME object via raw stdio MCP works fine —
proves the issue is client-side schema interpretation, not server.
Fix: add a json_object_schema helper that emits {type: object,
additionalProperties: true} and annotate every metadata + policy
field with #[schemars(schema_with = "json_object_schema")].
Affected fields:
- MintArgs.metadata
- MintUnsignedArgs.policy + .metadata
- Cip68NftArgs.metadata
additionalProperties is left wide-open since these args really do
accept arbitrary keys (CIP-25/CIP-68 are open-ended schemas).
Validates Track #30 vote bug. Working vote tx d9142849ac0f2525ab942a979c5662bf805a9e0c2184c4473f77e586ff3eeaee
on preprod_test2 prop #8, block 4690176. Three-day chase reduced to:
TX TTL slot ≠ slot underlying validity_upper_ms (which the MCP layer
clamped to voting_end). Validator's ppermitVote synthesizes the
expected Voted lock from chain's reconstructed validRange.upperBound
(derived from TTL slot); ours used the clamped value. Mismatch →
ponlyLocksUpdated assertion fails → silent UPLC error.
See audits/agora-vote-bug-2026-05-08/10-fix-validated.md for the
byte-level proof + comparison vs Clarity's working vote 4f2fac98....
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.
Mirrors the reference_script_path workaround already on wallet_send.
Lets the caller hand aldabra a path to a hex-CBOR file inside the
container instead of pasting the hex inline as a JSON-RPC arg, which
lets us bypass the 2026-05-07 MCP large-string transport bug (>~ 4500
char hex strings get a 1-byte truncation between Claude Code and
aldabra's stdio reader, surfacing as 'odd length' decode errors and
blocking debug-build minting policies).
policy_cbor_hex becomes Option<String>; new policy_cbor_path:
Option<String> sits next to it. New resolver helper
resolve_policy_cbor_bytes mirrors resolve_ref_script_bytes — at most
one of the two may be set, exactly one must be set. Whitespace is
stripped from file contents so the file may have trailing newlines.
Unblocks Track #33 / preprod_test3: debug-build validators that
overshoot the transport ceiling can now be minted via path arg.
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 `lucy-registry:5000/aldabra/mcp@0c79231` with
StakeRedeemer::RetractVotes and ProposalRedeemer::UnlockStake already
landed; this just wires the builder + MCP tool.
Same H-2-class issue as the advance Draft→VotingReady clamp. The
vote MCP tool computed validity_upper = tip_slot + 1799 then
errored if it overshot voting_end_check. On 30-min Sulkta-shape
DAOs the voting window is 30 min wide, and the moment chain time
crosses into the voting window the default 1799-slot upper bound
lands ~30 min past tip — already past voting_end if voting_start
was a few minutes ago.
Now: when default upper would overshoot, clamp validity_upper_slot
to voting_end_slot. Reject if remaining slots ≤ 5 (chain has no
room to include).
Caught 2026-05-08 trying to vote on preprod_test2 proposal #1
during a clean voting window — tx_upper landed 135s past
voting_end. Clamp lets the vote tx fit.
Cosign builder still has the same raw tip_slot pattern; deferred
since cosign also requires within-Draft semantics and we don't
have a multi-stake test yet to exercise it.
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. Cobb
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.
256 still underbid by ~16 bytes (721 lovelace) on the same
preprod_test2 governor mint shape that 128 missed by 144 bytes.
The actual CBOR delta between the unsigned tx (def-length witness
set arrays, no vkey witness, no redeemer expansion) and the signed
tx (indef-length flips, vkey witness, finalized redeemer) is
~270 bytes for this shape, not the 144 the first FeeTooSmallUTxO
suggested.
512 gives plenty of headroom — worst-case ~22k lovelace overestimate
which is trivial. For multi-sig flows with N vkey witnesses, this
needs revisiting; plutus_mint's only signer today is the wallet's
own payment key so a single-vkey budget is correct.
128 underbid by ~144 bytes on a 3-input plutus_mint with inline V2
policy CBOR (preprod_test2 governor bootstrap 2026-05-08, hit
FeeTooSmallUTxO 6353 lovelace short). The original constant covered
the vkey witness alone but missed redeemer ex_units final-pass
inflation + CBOR length-prefix shifts between unsigned (def-length)
and signed (often indef-length) witness-set arrays.
256 is plenty for any single-vkey case and still cheap fee-wise
(56-lovelace difference at the Cardano per-byte rate). For multi-sig
flows we'd revisit, but plutus_mint's only signer is the wallet's
own payment key.
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 044ebd23. 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.
Caught 2026-05-07: hex strings passed to MCP tool args > ~4500 chars
get a 1-byte truncation + structural rearrangement somewhere between
Claude Code and aldabra's stdio reader. Pallas + aldabra-core's
hex_decode are byte-clean (verified via crates/aldabra-dao/examples/
repro_script_corruption.rs); the corruption is purely in the
JSON-RPC-over-stdio transport layer.
Workaround: accept reference_script_path that points at a file
inside the aldabra container. Caller docker-cp's the hex file in,
passes the path via MCP arg, aldabra reads bytes locally — no
large strings cross the JSON-RPC wire.
Applies to wallet_send + wallet_send_unsigned. wallet_plutus_mint_*
tools still ride the hex-string path (small policies only, < 4500
chars). When we hit a Plutus policy that needs the workaround, port
the same pattern.
cargo run --example repro_script_corruption -p aldabra-dao --release
Reads a hex-encoded Plutus V2 script, builds a minimal Conway tx
with that script as inline reference, calls build_conway_raw, then
searches the tx body for the input bytes verbatim. Also tests the
known on-chain block-swap corruption fingerprint (bytes 2390-2398
swapped with bytes 2416-2424) to determine whether pallas
reproduces the corruption locally.
If verbatim found: pallas is byte-clean, bug is downstream
(transport / Koios / chain submit). If swapped variant found:
pallas itself produces the corruption.
No chain query, no MCP, no JSON-RPC — pure local serialization.
On Babbage+, Plutus's TxInfo.signatories is populated only from the
tx body's required_signers field — vkey witnesses alone don't surface
in the script context. Without this, any Agora script that calls
pauthorizedBy/txSignedBy fails. Stake-bootstrap on preprod erred
because the owner pkh wasn't in signatories despite the wallet
signing the tx.
MCP layer always passes [wallet_pkh]; callers can append cosigner
pkhs for multi-sig flows.
Companion to dump_governor (committed earlier this branch). Edit
owner_pkh_hex + staked_amount in the source, then `cargo run` to
print the inline datum CBOR for a wallet_plutus_mint_unsigned call
that mints StakeST + sends to stakes_addr.
No locks at bootstrap (locked_by = []) and no delegation
(delegated_to = None). For a stake that's been used in proposals,
locked_by would carry the ProposalLock entries; reuse this scaffold
when reseeding a stake from a snapshot.