Commit graph

61 commits

Author SHA1 Message Date
Kayos
326a559f76 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
Kayos
d1c9e7a732 security: enforce max_send_lovelace + sandbox *_path args
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.
2026-05-12 12:45:03 -07:00
45954f3f75 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
  (`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.
2026-05-10 21:29:40 -07:00
30a7137de5 feat(escrow): drop escrow_wip feature gate, ship as default surface
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.
2026-05-09 23:08:30 -07:00
eb192fa676 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
  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.
2026-05-09 14:06:17 -07:00
ad2d17e3d0 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
dba3d46271 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
c7f7dcb102 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
91c5d557b6 fix(mcp): force type:object on Value-typed metadata/policy args
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).
2026-05-09 09:23:34 -07:00
90883b50ce 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
e679874939 feat(mcp): add policy_cbor_path arg to wallet_plutus_mint_unsigned
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.
2026-05-08 18:50:26 -07:00
1b9968cf3b 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 `lucy-registry:5000/aldabra/mcp@0c79231` with
StakeRedeemer::RetractVotes and ProposalRedeemer::UnlockStake already
landed; this just wires the builder + MCP tool.
2026-05-08 13:24:24 -07:00
0c79231936 fix(mcp): clamp vote validity_upper to voting_end_slot when range overshoots
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.
2026-05-08 11:48:36 -07:00
a485a6f0bf 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
bf860dc99b 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. 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.
2026-05-08 10:19:06 -07:00
f44a4f209c 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
2d4c2163a9 mcp: add reference_script_path arg to bypass MCP large-string transport 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.
2026-05-07 16:45:53 -07:00
a2a1f72a74 plutus_mint: thread additional_signers into tx body's required_signers
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.
2026-05-07 10:33:55 -07:00
b50d45b5de feat(mcp): wallet_plutus_mint_unsigned MCP tool
Wires the new aldabra-core::plutus_mint module into MCP. Tool name
mirrors the unsigned-first DAO write convention (wallet_send_unsigned,
dao_proposal_*_unsigned, etc).

Args:
- policy_cbor_hex + policy_version (v1/v2/v3) + redeemer_cbor_hex
- mint_assets: array of {asset_name_hex, quantity}
- dest_address + dest_lovelace + dest_extra_assets + dest_inline_datum_cbor_hex
- required_input_refs: array of 'txhash#index' UTxOs that MUST be spent
- ex_units (mem + steps, optional — defaults to DEFAULT_EX_UNITS)

Returns the standard unsigned-payment shape ({cbor_hex, summary})
ready for wallet_sign_partial → wallet_submit_signed_tx.

Used for governor + stake + proposal bootstrap of any Plutus DAO.
For Agora preprod bringup, the typical call is:
- governor: required_input_refs=[gstOutRef], mint=[(GST,1)],
  dest=governor_addr, datum=GovernorDatum
- stake: mint=[(StakeST,1)], dest_extras=[(tTRP,N)], dest=stakes_addr,
  datum=StakeDatum
- proposal: spends GST input under the existing governor's spend
  redeemer + mints ProposalST under the proposal policy
2026-05-07 06:36:05 -07:00
a65ab7803e chore(mcp): drop unused build_signed_payment_with_assets / build_unsigned_payment_with_assets imports
Replaced direct calls in wallet_send + wallet_send_unsigned with the
new _extras variants in the previous commit. The _with_assets re-exports
are still in aldabra-core::lib.rs for any other callers, just not
imported into tools.rs anymore.
2026-05-07 06:21:33 -07:00
b9124ee5d9 feat(wallet): reference-script + extras on payment outputs
Adds Babbage/Conway-era reference-script attachment to wallet_send
and wallet_send_unsigned. The output can now carry any combination
of {native assets, inline datum, reference script}.

Why: deploying Plutus validators / minting policies as on-chain
reference UTxOs is the standard Cardano dApp pattern (Agora, Liqwid,
SundaeSwap all do this). Without it every spend or mint that uses
a script has to inline-witness the full CBOR — kilobytes per tx and
quadratic with tx size. Reference scripts let downstream txs witness
via `read_only_input` for ~32 bytes overhead.

API surface:

aldabra-core:
- new `ReferenceScriptSpec<'a> { kind: ScriptKind, cbor: &'a [u8] }`
- `ScriptKind` re-exported from pallas_txbuilder (PlutusV1/V2/V3/Native)
- new `build_signed_payment_extras(...)` and
  `build_unsigned_payment_extras(...)` — supersets of the existing
  `_with_assets` functions; take both `to_inline_datum_cbor` and
  `to_reference_script` Options
- existing `_with_assets` functions kept as thin wrappers that pass
  None for ref script — backwards compatible
- internal `output_with_assets`, `prepare_payment`, and
  `build_staging_with_fee` thread the new ref-script Option through

aldabra-mcp:
- `SendArgs` and `UnsignedSendArgs` gain
  `reference_script_cbor_hex: Option<String>` and
  `reference_script_kind: Option<String>`
- Both must be set or both omitted; mismatched returns a clean error
- `parse_script_kind` helper — case-insensitive, accepts
  PlutusV1/V2/V3/Native (plus shortcut V1/V2/V3)

Reference scripts are intentionally never attached to change outputs.
The change goes back to the wallet's own address, where a script
attachment would lock value into a publicized script that we'd then
have to spend BACK out — pointless. Ref-script attachment is only
on the recipient (`to`) output.

This unblocks Track B-fast step 3 of the preprod DAO bringup —
deploying the 11 Agora script bytecodes (governor / stakes /
proposal / treasury / mutate / noOp / treasuryWithdrawal validators
+ GST / StakeST / ProposalST / GAT minting policies) as reference
UTxOs against Kayos's preprod wallet.

No new tests in this commit — Phase 2 (Plutus-policy mint with
custom output) lands in a follow-up that will exercise this path
end-to-end against a real chain submit on preprod.
2026-05-07 06:17:07 -07:00
c695fb02f2 Revert "feat(dao): Phase 4c-bis-1 + 4c-bis-2 — typed EffectsMap + GAT policy config"
This reverts commit 09e8bb3e1d.
2026-05-06 10:39:51 -07:00
09e8bb3e1d 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
6a408c319a 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_kayos_stake — 50 Terrapin, owner pkh 84d0..f2f3,
  utxo d5b73a9d...#0
- decodes_sulkta_live_cobb_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
569c336f1d fix(dao-mcp): vote tool H-3 preflight reads from prop_datum (post-move)
The H-3 fix in commit a0daadf referenced target.datum.starting_time
+ target.datum.timing_config after target.datum had already been
moved into prop_datum upstream in the function. Switch to reading
from prop_datum directly. Pure compile fix; logic unchanged.
2026-05-06 08:08:35 -07:00
a0daadf38e fix(dao): audit punch list — H-1 to H-4 + M-2 + pallas bump
Lands the high-priority fixes from the 2026-05-06 audit before any
mainnet submit of the new vote/cosign/advance/destroy txs.

## H-1: Locked→Finished gate (MCP tool)

`dao_proposal_advance_unsigned` now refuses LockedToFinished unless
`tx_lower_ms > executing_end`. During the executing period the
validator demands gstMoved=true (governor input present); builder
doesn't include the governor input, so a tx in that window would
fee-burn. The proper Locked→Finished + GAT-mint flow is Phase 4c-bis;
this gate keeps us out of the broken middle.

## H-2 + H-4: strict-boundary + tx-upper-inside-period (MCP tool)

Validator's pgetRelation is strict on PAfter (`period_end < lb`)
and demands `ub <= period_end` on PWithin. Tool now picks PWithin
only when `tx_lower_ms >= period_start && tx_upper_ms <= period_end`,
PAfter only when `tx_lower_ms > period_end` strictly, and explicit-
errors on the boundary-straddling case (when tx validity range
crosses out of the target period). Same logic mirrored for the
VotingReady→Locked + VotingReady→Finished branches.

## H-3: vote builder lower-bound preflight (MCP tool)

`dao_proposal_vote_unsigned` previously checked only validity_upper
vs voting_end_ms. Validator demands BOTH `voting_start <= lb` AND
`ub <= voting_end`. Vote-too-early would hit "too early or invalid"
script error. New preflight on tx_lower_ms vs voting_start.

## M-2: DRep deposit pulled from ProtocolParams

Hardcoded constant (500 ADA) was wrong if the protocol changes
drep_deposit OR if the DRep was originally registered at a different
deposit amount (deregistration must match registration). Added
`drep_deposit_lovelace: u64` to ProtocolParams (default 500 ADA),
governance.rs build_signed_drep_registration / deregistration now
read from params instead of the constant. Constant kept for
backward compat with a doc note pointing at the params field.

## Pallas fork bump 507fd9da → 8091abd1

M-4 from the audit landed on the fork: voting_procedures builder
debug_assert_ne!s against empty CBOR map (0xa0) and docs the
upstream NonEmptyKeyValuePairs::decode footgun.

L-1 from the audit was a false finding — the audit subagent
misread the constants. PROPOSAL_CREATE_*_EX_UNITS are already at
the post-2026-05-05-H-2 values (5M mem / 2G steps per spend, 2M / 1G
per mint). The new builders alias these correctly. No change needed.
2026-05-06 08:06:44 -07:00
6443dcd858 feat(governance): wallet_drep_vote_cast + pallas voting_procedures patch
Phase 6, key-credentialed slice (script-DRep bridge for the DAO is the
remaining sub-arc).

## pallas-fork patch (Sulkta-Coop/pallas feat-aux-data HEAD 507fd9da)

Threads voting_procedures through StagingTransaction → conway::
build_conway_raw, mirroring the auxiliary_data + certificates patches.

- pallas-txbuilder/src/transaction/model.rs: voting_procedures field +
  builder methods .voting_procedures() / .clear_voting_procedures()
- pallas-txbuilder/src/conway.rs: VotingProcedures::decode_fragment on
  the way out, assigned to TransactionBody.voting_procedures
- BRANCH-NOTES.md: section 3 added documenting the new patch
- 2 new tests (round-trip + negative path) on the txbuilder side

aldabra Cargo.lock SHAs bumped to the new HEAD.

## aldabra-core/src/governance.rs

- VoteChoice enum (Yes/No/Abstain) with into_pallas() conversion
- build_signed_drep_vote_cast — assembles VotingProcedures CBOR
  (NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId,
  VotingProcedure>>) with this wallet's stake credential as a
  Voter::DRepKey, attaches via the new pallas API, dual-witness signs.
- Optional CIP-100 anchor on the vote.

## aldabra-mcp/src/tools.rs

- wallet_drep_vote_cast tool: gov_action_tx_hash + gov_action_index +
  vote (yes/no/abstain) + optional anchor.

What's still scope-of-Phase-6:
- Script-credentialed DRep voting (the DAO governor as DRep, with
  redeemer-driven authorization). Needs a different signing path
  since the voter is a script credential, not a key credential.
  Separate builder; defer until Sulkta wants to actually bridge.
2026-05-06 07:14:17 -07:00
4d3ef03978 feat(governance): Phase 5 — vote_delegate + drep_register/deregister
Conway-era governance MCP tools, key-credentialed (script credentials
deferred to Phase 6).

aldabra-core/src/governance.rs (new ~500 LOC):
- DRepTarget enum + parse_drep_target (handles bech32 drep1.../
  drep_script1... + named 'abstain' / 'no_confidence')
- build_signed_vote_delegation — Certificate::VoteDeleg(stake_cred,
  drep), reuses the dual-witness 2-pass-fee pattern from stake.rs.
  Optional register_first prepends StakeRegistration.
- build_signed_drep_registration — Certificate::RegDRepCert with
  optional CIP-100/119 anchor + 500 ADA deposit
- build_signed_drep_deregistration — Certificate::UnRegDRepCert with
  refund-aware change calc (deposit returns to wallet)
- DREP_REGISTRATION_DEPOSIT_LOVELACE constant (500 ADA, mainnet)

Made stake_key_as_payment_proxy pub(crate) so governance.rs can reuse
the stake-key-as-witness trick.

aldabra-mcp/src/tools.rs:
- wallet_vote_delegate (drep + register_first)
- wallet_drep_register (optional anchor_url + anchor_data_hash_hex)
- wallet_drep_deregister (no args)

3 unit tests on parse_drep_target + DRepTarget→DRep round-trip.

Phase 6 (vote_cast for DReps voting on Conway gov actions) blocked
on extending Sulkta-Coop/pallas-txbuilder to thread voting_procedures
through StagingTransaction (currently TODO at conway.rs:254). Same
pattern as the aux_data + certificates patches already in the fork.
Estimated ~300-500 LOC fork patch + ~400 LOC vote-cast builder. Surface
to Cobb before starting.
2026-05-06 07:08:08 -07:00
d007817796 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
39b56223f9 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
68e493dd2f 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
3b0e0dd9bf 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
5102c77972 chore(dao): drop unused imports — ScriptRefs to test scope, StakeDatum gone from tools.rs 2026-05-05 20:58:03 -07:00
893e3f23da feat(dao-mcp): wire dao_proposal_create_unsigned to fetch C-2 inputs from chain 2026-05-05 20:57:36 -07:00
9556b7812d fix(dao): audit H-2 + H-5 + H-6 (per memory/audit-aldabra-dao-2026-05-05)
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
edd1948dec 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
5913b9266a feat(dao-mcp): dao_register accepts Phase-4 fields in one call
Closes the gap between DaoConfig schema (already has fields) and the
dao_register tool (was rejecting/ignoring them). Now Sulkta DAO can be
registered with all audit-discovered values in one call:
  proposal_addr / stake_st_policy / proposal_st_policy +
  5 reference UTxO refs (governor / stake / proposal validators +
  StakeST / ProposalST minting policies).

All fields remain optional. dao_proposal_create_unsigned errors clearly
when one's missing. Future dao_discover_scripts tool will auto-populate
from chain queries.
2026-05-05 20:10:22 -07:00
93edf0c9c3 feat(dao-mcp): wire dao_proposal_create_unsigned + drop unused imports 2026-05-05 19:50:17 -07:00
3a7f536409 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
5fb616c6c5 feat(dao): wire 8 dao_* MCP tools (Phase 1)
Tools added to WalletService:

DAO management (filesystem-only, no chain calls):
- dao_register      — save a DaoConfig under \$ALDABRA_DATA/daos/<name>.json
- dao_list          — show all registered DAO names + active marker
- dao_use           — set active DAO; subsequent dao_* calls without
                      explicit `dao` arg target this one
- dao_remove        — delete config; clears active if it was the active one
- dao_show          — render full DaoConfig JSON for audit

DAO live-state reads (Koios-backed, decoded into typed Rust):
- dao_governor_state  — singleton governor UTxO + thresholds + timing
                        + nextProposalId + per-stake proposal cap
- dao_stake_list      — all stakes for the DAO (filtered to gov-token
                        policy so the shared MLabs stakes addr doesn't
                        leak other DAOs into output). Renders pkh,
                        amount, locks, delegation per stake.
- dao_my_stake        — filters dao_stake_list to just THIS wallet's
                        stake (matches wallet pkh against StakeDatum.owner).
                        Empty array if not staked yet.

Plumbing:
- WalletService::new gains data_dir param (for DaoStore root)
- WalletInner gains dao_store + dao_reader fields
- wallet_pkh() helper extracts the wallet's payment-credential hash from
  bech32 for owner-match in dao_my_stake
- get_info() instructions advertise the new dao_* surface
- aldabra-mcp/Cargo.toml: aldabra-dao path dep + hex + pallas-addresses
2026-05-05 13:51:04 -07:00
66829c9aea mcp: declare tools capability in ServerInfo
rmcp 0.1.5's #[tool(tool_box)] macro doesn't backfill
ServerInfo::capabilities. Without an explicit ToolsCapability,
clients read "capabilities":{} from initialize and skip tools/list
entirely — the server looks connected (instructions field lands)
but the tool surface is empty. Claude Code's MCP log:
  "hasTools":false,"hasPrompts":false,"hasResources":false

Fix: capabilities = ServerCapabilities::builder().enable_tools().build()
in get_info(). Adds a regression test on the wire shape.
2026-05-05 09:41:19 -07:00
ffdafc2028 v0.2: 8 chain_* read-only Koios passthrough MCP tools
Adds a parallel read-only API surface alongside wallet_*:

  chain_tx_info        full Koios tx_info (any hash)
  chain_address_info   balance + utxos at any address
  chain_pool_list      filter by ticker / pool_id_bech32
  chain_pool_info      detail per pool (delegators, blocks)
  chain_epoch_params   protocol params for an epoch
  chain_asset_info     supply, holders, mint history
  chain_account_info   stake address state
  chain_tip            current chain tip

All passthrough — Koios JSON returned verbatim, no re-shaping.
Network-aware via existing ALDABRA_KOIOS_BASE; mainnet vs preprod
just changes the URL. No keys touched, no signing path. Saves
the bash-curl friction Cobb flagged 2026-05-05 mid-mainnet
testing arc.

Wire-up: KoiosClient gets `post_raw_json` + `get_raw_json`
helpers that return raw response strings instead of decoding
into typed structures. The chain_* tools are thin wrappers
around those.

ServerInfo `instructions` updated to advertise the chain_*
surface alongside wallet_*.
2026-05-05 07:01:32 -07:00
1ee124b545 AUDIT4-3 fix: optional inline datum on wallet_send
wallet_send + wallet_send_unsigned now accept an optional
datum_inline_cbor_hex field. When set, the recipient output
carries the bytes as an inline datum — the right shape for
locking funds at a script address with a datum the validator
can read.

Without this, sends to script addresses created un-spendable
utxos (Babbage/Conway rejects spending script utxos that
don't carry a datum). Surfaced 2026-05-04 audit-4 phase F2
when the always-succeeds Aiken validator's locked utxo
couldn't be spent back due to NotAllowedSupplementalDatums +
PPViewHashesDontMatch chain errors.

Plumbed through:
  build_signed_payment_with_assets (added arg)
  build_unsigned_payment_with_assets (added arg)
  prepare_payment (added arg)
  build_staging_with_fee (added arg)
  output_with_assets (added arg)
  SendArgs / UnsignedSendArgs (new optional MCP field)

Change outputs never get a datum — they go back to the wallet
which has no validator to satisfy, so the field is wired only
to the recipient output.

Test lock_with_inline_datum_attaches_datum_to_output decodes
the resulting tx CBOR and confirms the recipient output's
datum_option is populated.

Unblocks mainnet Plutus testing — the spend round trip can
now build a lock that the spend side can satisfy.
2026-05-05 06:58:15 -07:00
e712f370f0 aldabra: --bootstrap-from-xprv power-user import path
Adds RootKey::from_root_xsk_bech32() / from_xprv_bytes() /
to_root_xsk_bech32() so RootKey can ingest + emit the same
bech32 root extended secret key shape that cardano-cli +
cardano-address + the IOG node priv/wallet/<name>/root.prv
file already use. HRP is strictly root_xsk — refuses
acct_xsk/addr_xsk to keep the import scoped to the actual
HD root.

New CLI flag --bootstrap-from-xprv runs an interactive
import: paste root_xsk1... bech32, prompt passphrase,
encrypt, persist as root-xprv.age (parallel to mnemonic.age).
Refuses to overwrite either existing key file (per Cobb's
no-delete-crypto-keys rule — caller has to move aside, not
delete).

Startup path now checks for either mnemonic.age OR
root-xprv.age; refuses if both exist (ambiguous). Same
RootKey downstream — derivation tree, signing, all of it
works identically whether the key came in via mnemonic
or xprv import.

Test root_xprv_round_trip proves the imported xprv derives
to the same address as the mnemonic-imported equivalent.
2026-05-05 06:38:01 -07:00
30761039ea AUDIT4-G2 fix: client-side min_utxo guard on ada-only wallet_send
wallet_send now rejects sub-min-utxo (1 ADA) ada-only sends with a
clear local error before any koios round-trip. Asset-bearing sends
still go through to chain so the dynamic per-asset min computation
is what surfaces in the error — no static guard would be right
there.

Saves the chain round-trip + the bewildering "tx submitted... wait
30 seconds... actually it failed" UX. Surfaced 2026-05-04 audit-4
phase G2 against the deployed container.
2026-05-04 21:22:28 -07:00
d5fb00c9f5 mainnet prep: tighten max_send_lovelace default + cost model docs
max_send_lovelace default is now network-aware: mainnet 10 ADA,
preprod/preview 100 (t)ADA. Mainnet handles real value, so the cap
should bite earlier — anything > 10 ADA needs explicit force=true.
Test ada on preprod/preview is faucet-replaceable, no need to
sand off the test surface. New regression
mainnet_default_max_send_is_tighter locks the rule in.

PLUTUS_V3_COST_MODEL_PREPROD docstring updated: confirmed identical
to mainnet PV3 cost model (preprod epoch 286 = mainnet epoch 629,
both 297 params, byte-identical). Cost models are protocol-version
parameters, not network parameters; using the same constant on
both is correct. Re-snapshot from mainnet Koios after any major
hard fork. Naming kept as _PREPROD for git churn reasons.
2026-05-04 21:09:31 -07:00
47b63f2024 AUDIT4-1 fix: switch tx_status from Koios /tx_info to /tx_status
The old impl called Koios /tx_info to learn confirmation state. For
confirmed txs that endpoint streams the full tx body — multi-MB on
complex txs, hundreds of KB on trivial ones — and the public Koios
endpoint either rate-limits or chunks slowly enough to escape our
10s reqwest timeout. Result: wallet_tx_status hung 120s+ and the
container subprocess died, surfaced 2026-05-04 audit-4 phase C7.

Fix: call the lighter /tx_status endpoint, which returns a single
{tx_hash, num_confirmations} record per tx — bytes, not MB.

API change: TxStatus::Confirmed { block_height, epoch } becomes
TxStatus::Confirmed { num_confirmations }. The endpoint doesn't
return block_height / epoch anyway; num_confirmations is what
callers actually want for polling-until-final flows. wallet_tx_status
docstring updated to spell out the three returnable shapes.

Tests: drops the KoiosTxInfo-shape unit tests, adds
parses_koios_tx_status_shapes covering the three live response
shapes we observed (confirmed-with-count, known-but-no-confs,
empty array).
2026-05-04 20:45:10 -07:00
f23ff65dad audit-3 (code cleanup): zero clippy warnings, zero build warnings
prep for deployment. cargo clippy --workspace --all-targets now passes
clean. cargo audit unchanged (same 2 unmaintained-warning macro-support
transitives; no cves).

cleanup applied:
- ProtocolParams construction in tools.rs uses struct-update syntax
  (clippy::field_reassign_with_default).
- main.rs collapsed two else-if branches with identical bodies
  (clippy::if_same_then_else).
- mint/plutus/stake sort_by(|a,b| b.cmp(&a)) → sort_by_key(Reverse(_))
  (clippy::manual_sort_by). 4 sites.
- metadata/mint/tx odd-length hex check uses .is_multiple_of(2)
  (clippy::manual_is_multiple_of). 3 sites.
- stake.rs witness_overhead conditional removed — both branches
  produced TWO_WITNESS_OVERHEAD_BYTES (left over from when
  registration was thought to add a third witness; it doesn't).
  WITNESS_OVERHEAD_BYTES const removed (only the two-witness one
  is used).
- Public spend/mint/stake build_signed_*_with_assets fns get
  #[allow(clippy::too_many_arguments)] — they ARE the API surface.
- ex_units_default_is_generous test gets explicit allow for the
  tautological-on-const assertion (kept the intent comment).

97 unit tests still pass. release build clean.
2026-05-04 18:40:35 -07:00
7d59ceffd2 plutus spend: fix all 4 chain-level bugs surfaced in preprod audit
PLUTUS-1 (HIGH) — value-not-conserved on happy path. collateral isn't
consumed unless script fails, so total_in counted lovelace that wasn't
actually available for outputs. now picks a SEPARATE ada-only funding
utxo as a regular input alongside the locked utxo; collateral stays
collateral. error message tells callers to "split a UTXO first or top
up" if a second ada-only utxo isn't available.

PLUTUS-2 (HIGH) — collateral containing native assets. chain forbids
that; our picker grabbed largest-overall. now filters available_utxos
to assets.is_empty() before picking, errors clearly if no ada-only
utxo ≥ 5 ADA exists.

PLUTUS-3 (HIGH) — fee underestimation. plutus tx fees are
size_fee + exunits_fee. only size_fee was being charged. new
ProtocolParams::ex_units_fee() does ceil(mem * priceMem) +
ceil(steps * priceStep). conway-era prices in defaults
(577/10000 mem, 721/10_000_000 steps). fee jumps from ~0.17 ADA →
~1.7 ADA for the default ExUnits budget — matches what chain demanded.

PLUTUS-4 (LOW, becomes blocking under the others) — script_data_hash
not computed. pallas-txbuilder only computes the body hash field when
language_view is set on staging. plutus v3 path now calls
.language_view(version, cost_model) when the caller-supplied
ProtocolParams::plutus_v3_cost_model is Some. mcp wallet_script_spend
populates with the canonical preprod V3 cost model from
plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD (297 i64 params,
fetched from koios epoch_params 2026-05). when ProtocolParams has no
cost model, we skip language_view and the chain rejects with
PPViewHashesDontMatch — explicit-failure mode, no silent shipping
of broken txs.

new tests:
- ex_units_fee_matches_known_values: 14M mem * 0.0577 + 10B steps *
  7.21e-5 ≈ 1.529 ADA ± ceil-rounding. locks the conway price math.
- rejects_when_no_funding_input_separate_from_collateral: catches
  the PLUTUS-1 single-utxo case.
- rejects_when_collateral_candidate_has_assets: PLUTUS-2 ada-only.

verified on preprod against a real script-locked utxo (the placeholder
script we locked 5 tADA at earlier). chain rejection went from 5
distinct errors to 1 (MalformedScriptWitnesses — expected, our
placeholder UPLC isn't valid). structural body shape now passes
every chain-rule check; only the script bytecode itself fails to
compile, which is a test-env limitation (no aiken in our toolchain
yet) not a wallet-code limitation.

97 unit tests pass. ProtocolParams gained 5 new fields + ex_units_fee
helper; went from Copy to Clone (cost_model is a Vec).
2026-05-04 17:27:47 -07:00
05292f182e preprod live-test fixes: 4 real bugs surfaced in real-koios + chain integration
discovered during preprod smoke 2026-05-04 — 7 txs submitted (3 sends,
2 mints, 1 cip68 nft mint, 1 burn). all confirmed on chain. unit-test
coverage missed these because hand-crafted koios fixtures didn't match
real-world response shapes.

bugs:

PREPROD-1 (HIGH) — KoiosUtxo::asset_list deserializer rejected `null`.
real /address_utxos returns asset_list:null for ada-only utxos (vs
/address_info which returns []). Vec<T> can't deserialize null, killing
the entire utxo response. Option<Vec<T>>.unwrap_or_default fixes it +
new regression test deserializes_utxo_with_null_asset_list locks it in.

PREPROD-2 (HIGH) — /address_utxos needs `_extended: true` to populate
asset_list. without it, koios returns asset_list:[] (or null) for
asset-bearing utxos, making the wallet think it has zero of its own
tokens. native-asset send fails with "insufficient asset". new
AddressesExtendedBody serializer; get_utxos sets _extended=true.

PREPROD-3 (MEDIUM) — wallet_mint_cip68_nft default lovelace was 1.5 ADA
but the babbage min-utxo formula for inline-datum-bearing outputs
clears ~1.79 ADA. chain rejected with BabbageOutputTooSmallUTxO.
bumped default_token_lovelace 1_500_000 → 2_500_000 (covers typical
cip-68 metadata; large metadata still requires caller override).

PREPROD-4 (LOW, audit-process) — submit_tx error path called
.error_for_status() which discards koios's response body. chain-rule
rejections came through as bare HTTP codes, no diagnostic. now we
capture status + body before checking; rejections include the actual
ledger error (e.g. BabbageOutputTooSmallUTxO with the offending coin
amounts) so future debugging is one-shot.

7 successful preprod txs:
- e3e52cf9 self-send 3 ADA
- 397fe6b7 self-send 5 ADA via cold-sign flow (build_unsigned →
  tx_summary → sign_partial → submit_signed_tx; predicted tx_hash
  matched submitted tx_hash, body invariant under signing confirmed)
- d23e4c60 mint 100 ALDABRA_TEST with CIP-25 metadata
- 25cc489c mint cip-68 nft pair (ref label 100 + user label 222)
- 2ce72b6f mint 50 more ALDABRA_TEST via unsigned-mint flow
- 19a909df native-asset send (25 ALDABRA_TEST + 5 ADA)
- f949d29c burn 10 ALDABRA_TEST (negative-quantity mint)

guards verified:
- max_send_lovelace cap rejects 200 ADA without force ✓
- mint with insufficient holdings rejected with clear error ✓
- mcp tool names with dots silently dropped by Claude Code validator
  (already fixed in previous commit by renaming to underscore-only)

94 unit tests pass.
2026-05-04 16:57:40 -07:00