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.
Phase 4c + 4d. Closes the DAO write-path arc (excluding GAT minting,
which is Phase 4c-bis since Sulkta has never executed a proposal).
## proposal_advance (Phase 4c)
State-machine builder with 5 transitions:
- Draft → VotingReady (cosigner threshold met, all cosigner stakes
ref'd as txInfo.referenceInputs, sum staked_amount ≥ to_voting)
- Draft → Finished (drafting period elapsed without enough cosigners)
- VotingReady → Locked (winner outcome exists with votes ≥ execute,
no tie)
- VotingReady → Finished (locking period elapsed without winner)
- Locked → Finished (executing period elapsed; for InfoOnly proposals)
Validator (PAdvanceProposal in Proposal/Scripts.hs:657) requires
output proposal datum equals input with ONLY status mutated. Builder
mirrors exactly. Per-transition preflights match validator gates.
Cosigner stake refs go in as txInfo.referenceInputs (not regular
inputs) per witnessStakes pattern (Proposal/Scripts.hs:366) — sum
of staked_amount is computed from the ref-input set.
GAT-minting Locked→Finished path (effected proposals) deferred to
4c-bis. The pmintGATs governor redeemer is a separate tx that fires
ONLY when the executing period is in window AND the winner outcome
has effects to mint GATs for. Sulkta's first proposal was InfoOnly
so this path never exercised on chain yet.
11 unit tests covering every transition + every preflight reject.
## stake_destroy (Phase 4d)
Burns StakeST token + returns gov-tokens to owner. From
Stake/Redeemers.hs pdestroy (~L432): owner signs (no delegatees),
all locks empty, no stake output at stakes_addr. From stakePolicy
burn branch (~L161): burntST quantity = -spentST.
Tx shape: spend stake (Destroy redeemer) + maybe a funding utxo +
collateral; mint -1 StakeST; one wallet output carrying gov-tokens
+ (stake.lovelace + funding - fee). Funding optional — stake's own
lovelace usually covers fees.
4 unit tests including the funding-optional path.
## MCP tools
dao_proposal_advance_unsigned auto-picks the right transition from
proposal status + chain tip vs window boundaries. Mainnet-only gate.
Fetches cosigner stake refs by matching owner pkh against
proposal.cosigners.
dao_stake_destroy_unsigned fetches the wallet's stake (via owner
pkh match), pulls StakeST asset name from chain, burns it.
Phase 4b. Cosign extends a Draft proposal's cosigners list — the
multi-stake bridge for clearing to_voting threshold when a single
stake doesn't have enough TRP. Validator (PCosign branch in
Proposal/Scripts.hs:433) requires:
- Status == Draft
- Exactly one stake input (ptryFromSingleton)
- New cosigner = stake.owner (delegatees rejected)
- Cosigner inserted into list via pinsertUniqueBy (sorted, no dupes)
- len(cosigners) ≤ max_cosigners (DaoConfig.max_cosigners)
- stake.staked_amount ≥ thresholds.cosign
Stake-side (ppermitVote PCosign branch): owner signs (not delegatee),
single stake input, new lock = ProposalLock { proposal_id, Cosigned }
prepended via paddNewLock = pcons.
Insertion order mirrors Plutarch's pfromOrdBy-derived Credential Ord:
variant index first (PubKey=0 < Script=1), then 28-byte hash lex.
`insert_unique_sorted` test-covered for low/mid/high positions + the
PubKey-before-Script invariant.
Also extract pull_wallet_utxos free function in tools.rs — shared
between the (future) refactor of create/vote and immediately by
cosign. Inline duplication in create/vote left as a future cleanup.
11 unit tests on the builder. Tool args: dao? + proposal_id +
fee_lovelace.
The first attempt's vote MCP tool inlined a Koios address_info pull
helper in tools.rs that needed reqwest + pallas_codec + pallas_primitives
as direct deps on aldabra-mcp — which it doesn't have. Compile failed.
Cleaner: move the work into the dao crate where those deps already live.
- ProposalUtxo gains `lovelace` + `proposal_st_asset_name_hex`. The
vote builder needs both to construct the new proposal output.
- KoiosDaoReader::list_proposals (was stubbed) now reads cfg.proposal_addr,
decodes every UTxO's inline datum to ProposalDatum, and matches the
ProposalST asset name against cfg.proposal_st_policy when set, falling
back to the first asset on the utxo when not (Sulkta convention is one
ProposalST + nothing else).
- KoiosAsset.asset_name no longer #[allow(dead_code)] — it's read now.
- tools.rs::dao_proposal_vote_unsigned switches to dao_reader.list_proposals
+ drops the inline pull helper. ~150 LOC simpler.
- DaoProposalVoteArgs (dao? + proposal_id + result_tag + fee_lovelace)
- mainnet_slot_to_posix_ms: Shelley genesis constants (slot 4_492_800,
posix 1_596_059_091_000) for converting tip+VALIDITY_RANGE_SLOTS
into the Voted lock's posix_time field
- pull_proposal_utxos helper: address_info → decode every UTxO's
inline datum into ProposalDatum, return matching proposal_id by id
match (`KoiosDaoReader::list_proposals` is still stubbed; this is
a focused write-path read)
- mainnet-only network gate (preprod/preview slot↔ms is Phase 5)
- get_info instructions text mentions write tools
- drop unused pallas_addresses::Address + pallas_txbuilder::ExUnits
imports surfaced by clippy
H-2: drop ExUnits to 5M/2G for spend, 2M/1G for mint
Was 14M/10G each = per-tx Conway cap. With 3 plutus contracts running
(governor spend + stake spend + ProposalST mint), total claim 42M/30G
exceeds per-tx limit and node rejects pre-phase-2.
H-5: propagate malformed wallet asset keys instead of silently dropping
Previous filter_map silently dropped any key < 56 chars. Could let a
corrupt Koios response burn assets on submit. Now returns explicit
Err with the offending UTxO + key.
H-6: tighten StakeST detection to asset_name == stake_validator_hash
Per Stake/Scripts.hs:188-190 (pscriptHashToTokenName), StakeST
asset_name is the stake validator's script hash. Previous code took
"first non-gov-token asset" which would silently pick a wrong policy
if a stake UTxO accidentally carried a junk NFT. Regression test
h6_junk_token_does_not_pollute_stake_st_detection added.
3 of 7 audit punch-list items closed. C-1 + C-2 + C-3 next.
New `aldabra-dao::discovery` module:
- `DiscoveryClient` trait + `KoiosDiscoveryClient` impl
- `discover_scripts(cfg, client, deployers)` — auto-finds:
- governor_validator_ref + stake_validator_ref via deployer ref-script search
- stake_st_policy from any existing stake UTxO (gov-token + non-gov-token asset)
- stake_st_policy_ref via deployer search
- `apply_discovery(cfg, report)` — merges into DaoConfig (never overwrites)
- `script_hash_from_addr(bech32)` — extract 28-byte script hash from a script address
New MCP tool:
- `dao_discover_scripts { dao?, extra_deployers? }` — runs the audit logic
against any registered DAO + persists the discovered fields back to the
DaoConfig. Returns JSON with what was found + a gaps list for things
v1 can't auto-discover (proposal_addr, proposal_st_policy).
Plus 4 unit tests with stub Koios responses validating the full pipeline:
script-hash extraction, StakeST discovery from stake UTxO assets,
validator ref-utxo matching at deployer, apply_discovery merge semantics.
WalletInner now caches `koios_base` so the discovery client can be
constructed on demand without re-passing the URL through args.
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.
DaoConfig gains optional fields for Phase 4 (proposal_create) work:
- proposal_addr — proposal validator address (bech32)
- stake_st_policy — StakeST minting policy id (56 hex)
- proposal_st_policy — ProposalST minting policy id (56 hex)
- script_refs — cached reference UTxO refs for each Agora script
(governor / stake / proposal / treasury validators
+ stake_st / proposal_st minting policies)
All fields optional with serde defaults so existing configs keep loading.
Will be populated by upcoming `dao_discover_scripts` MCP tool that audits
on-chain state under a known governor_addr.
Test fixture also corrected: stakes_addr now uses Sulkta's real per-DAO
parameterized stake-validator address (`addr1w8msu7p...`) instead of the
shared MLabs deployer (`addr1w9gexmeunzsy...`) — matches audit findings.
aldabra-mcp dao_register tool initializes new optionals to None so
DaoConfig construction stays explicit.
Verified against Sulkta's live Proposal #0 datum 2026-05-05:
status field is bare BigInt(3), not Constr 3 []. Plutarch's
EnumIsData derive emits Integer-as-index in this Agora version.
Affected:
- ProposalStatus.{to,from}_plutus_data
- GovernorRedeemer.to_plutus_data (consistency; no on-chain
governor-redeemer evidence yet, but same EnumIsData derive)
ProposalDatum.to_plutus_data signature updated for the new fallible
ProposalStatus encoding (now returns DaoResult).
Added regression test `decodes_sulkta_live_proposal_zero` that decodes
Proposal #0's actual on-chain datum hex and asserts:
proposal_id=0, status=Finished, cosigners=[Cobb's pkh],
thresholds=20/100/100/1/1, votes={0:0, 1:0} (zero votes ever cast),
starting_time=1772666551575ms.
Closes audit findings 1 + 2 from memory/audit-sulkta-agora-2026-05-05.md.
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
The Plutarch `ProductIsData` derive (used by every record datum in
Agora) emits a CBOR list of fields, NOT the generic Constr 0
encoding I assumed during Phase 0. Verified by decoding Sulkta's
live governor UTxO datum: outer bytes start `9f 9f` (indef array of
indef arrays), not `d8 79` (Constr tag 121).
Affected types:
- StakeDatum, ProposalLock (was Constr 0, now Array)
- ProposalDatum, ProposalThresholds, ProposalTimingConfig
- GovernorDatum
Sum types untouched — they keep Constr-encoding (makeIsDataIndexed
or EnumIsData both produce Constr i [...]):
- Credential, ProposalAction, StakeRedeemer, ProposalRedeemer,
GovernorRedeemer, ProposalStatus
New helpers in plutus_data.rs:
- `product(fields)` — emit indefinite-length CBOR Array
- `as_product(pd)` — decode (alias for as_array, named for intent)
Added end-to-end validation test `decodes_sulkta_live_governor_datum`
that decodes the real on-chain datum hex from Sulkta's governor UTxO
(7c8db14...221c47#1) and asserts the parsed struct matches README
parameters: thresholds [20/100/100/1/1], 7d draft, 7d vote, 48h lock,
24h exec, 30min ranges, max 20 proposals per stake.
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.
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_*.
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.
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.
Two coupled fixes for the same root cause: the coin selector was too
conservative for "send most of what I have" cases.
1. min_change_required now drops to 0 for ada-only sends (kept at
min_utxo_lovelace for asset-bearing sends where change has to
carry leftover policy IDs). Downstream pass2 already folds
sub-min change into fee on the ada-only happy path; the selector
was reserving slack the chain doesn't actually need.
2. fee_pass1 dropped from 500_000 to 200_000. Real fees:
1-in 1-out ada-only send : ~166 k
1-in 2-out (with change) : ~178 k
CIP-25 mint w/ metadata : ~210 k
500_000 was overgenerous safety budget. 200_000 is enough headroom
for the basic-send case (which is the one that needed to drain to
fee) without crowding mint paths (which typically have plenty of
lovelace headroom anyway).
Surfaced 2026-05-05 zeroing out the mainnet test wallet:
2 ADA balance, 1.8 ADA send refused upstream as
"need 3300000 (target+fee+min_change), have 2000000"
even though the chain math was fine. New regression
ada_only_send_can_drain_to_fee covers the case.
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.
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.
build_signed_plutus_spend was picking the LARGEST ada-only utxo
for collateral and the next-largest for funding. Wallets with
one big change utxo + a small leftover (the typical shape after
any send) hit this with funding=tiny, collateral=huge —
funding+locked couldn't cover payout + script-execution fee +
change min_utxo even with billions of lovelace sitting unused
in collateral.
Fix: pick the SMALLEST ada-only utxo that still qualifies (≥5 ADA)
for collateral, and the LARGEST for funding. Collateral never
gets consumed on the happy path, so its size beyond the 5-ADA
floor is wasted budget; funding has to cover real spend.
Surfaced 2026-05-04 audit-4 phase F2 on the deployed Lucy
container against the always-succeeds Aiken validator.
New regression test picks_smallest_qualifying_collateral_largest_funding
covers the mixed-size-utxo scenario the prior tests missed
(both old utxos were 50-100M ada, so the inversion didn't show).
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).
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.
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).
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.
Claude Code's MCP client validates tool names against
[a-zA-Z0-9_-]{1,64} and silently drops names containing dots.
aldabra was registering wallet.address etc. with dots; despite the
daemon running fine and rmcp accepting the names, Claude Code's
tools/list cache was empty for aldabra after `/exit + relaunch`.
discovered integration-time 2026-05-04 after first real session
restart with the wallet registered.
renamed:
wallet.address → wallet_address
wallet.network → wallet_network
wallet.balance → wallet_balance
wallet.utxos → wallet_utxos
wallet.send → wallet_send
wallet.send.unsigned → wallet_send_unsigned
wallet.tx_status → wallet_tx_status
wallet.tx_summary → wallet_tx_summary
wallet.sign_partial → wallet_sign_partial (already underscored)
wallet.submit_signed_tx → wallet_submit_signed_tx (ditto)
wallet.policy.create → wallet_policy_create
wallet.mint → wallet_mint (no change)
wallet.mint.cip68_nft → wallet_mint_cip68_nft
wallet.mint.unsigned → wallet_mint_unsigned
wallet.script.spend → wallet_script_spend
wallet.stake.address → wallet_stake_address
wallet.stake.delegate → wallet_stake_delegate
instructions blurb + module docstring updated. all 93 unit tests
still pass. fresh tools/list smoke confirmed: 17 tools all
underscore-only.
cobb needs to /exit + relaunch one more time for Claude Code to
re-handshake with the rebuilt binary.
HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
mint, witness count, aux-data presence). new aldabra-core::inspect
module. callers MUST run this before wallet.sign_partial /
wallet.submit_signed_tx on any cbor they didn't build themselves.
MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
OpenOptionsExt. removes the prior toctou window between fs::write
(default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
in quotes can no longer round-trip as a fake tx_hash.
LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
pattern, can't overflow with realistic cardano amounts but
defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
after deriving payment_key + stake_key + address. saves ~96 bytes
of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
confirmed case. previously rendered as Confirmed{block_height: None}
which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.
WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
with the rand feature. returns (Mnemonic, Zeroizing<String>) so
the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.
mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.
audit doc memory/spec-aldabra-audit-2026-05-04.md updated with
status markers.
stake key + reward address (4.5):
- StakeKey::stake_address(network) — bech32 (`stake1...` mainnet,
`stake_test1...` testnet) via pallas_addresses::StakeAddress::new
(added to the fork in the same commit since the upstream tuple
struct had no public constructor).
- StakeKey::xprv() — crate-internal accessor for signing.
- WalletInner now holds the stake_key alongside the payment_key.
- mcp tool wallet.stake.address surfaces the bech32.
stake delegation (4.6):
- new aldabra-core::stake module:
- parse_pool_id(bech32) → Hash<28>
- build_signed_stake_delegation(payment, stake, network, utxos,
change_addr, pool_bech32, register_first, params) → signed cbor.
- if register_first: prepends a StakeRegistration cert (consumes
a 2 ADA deposit from inputs). otherwise just delegates.
- signs with both payment_key (body witness) and stake_key (cert
witness). reuses sign::add_witness for both — same body-hash
ed25519 signing path regardless of CIP-1852 chain index.
- mcp tool wallet.stake.delegate: pool_id, register_first (defaults
true). signs + submits.
3.6 close-out — wallet.mint.unsigned mcp tool:
- exposes the existing build_unsigned_mint with caller-supplied
PolicySpec (json), so multi-sig / treasury flows can build through
this wallet without it auto-signing. round-trip with
wallet.sign_partial chain → wallet.submit_signed_tx.
depends on Sulkta-Coop/pallas@feat-aux-data which gained two more
patches in the same branch:
- StakeAddress::new public constructor.
- StagingTransaction::add_certificate / clear_certificates +
Conway::build_conway_raw decode-and-plumb for certs (filling in the
`certificates: None, // TODO` upstream).
mcp tools: 12 → 15 (wallet.stake.address, wallet.stake.delegate,
wallet.mint.unsigned).
79 → 84 unit tests. new coverage: stake address bech32 round-trip,
pool_id bech32 parse + reject-wrong-hrp, delegation tx with + without
registration (asserts cert count, witness count, cert variants).
fork tests grew: certificates_plumb_through_to_tx_body and
no_certificates_means_none.
new aldabra-core::cip68 module:
- asset name prefixes 100 (0x000643b0 ref) / 222 (0x000de140 user) /
333 (0x0014df10 ft). prefixed() guards 32-byte total cap so caller
can't blow past the cardano protocol limit by accident.
- json_to_plutus_data: serde_json::Value → PlutusData (recursive).
numbers must fit i64. strings → BoundedBytes (cip-68 convention is
bytes-keyed datum maps, not text). null is rejected, floats rejected.
- build_cip68_datum_cbor wraps the metadata in the canonical
Constr 0 [meta_map, version_int=2, Constr 0 []] shape.
new aldabra-core::mint::build_signed_cip68_nft_mint:
- mints two assets simultaneously under one policy (ref + user, qty 1
each), three outputs (ref @ ref_addr w/ inline datum, user @ user_addr,
change). same two-pass fee refinement as the rest of the path.
- mutable nfts: pass ref_addr == change_addr. wallet's payment key can
later spend the ref UTXO and re-create with new datum.
- immutable: caller passes an always-fails script address (phase 4
concern; today this fn trusts whatever's passed).
new aldabra-core::sign module + add_witness:
- decodes a conway tx (any state — unsigned or partially signed),
signs the body hash with the wallet's payment key, appends a
VKeyWitness to the witness_set, re-encodes. body is invariant
(regression test asserts the body hash before and after the witness
append are identical).
- this is the missing primitive for n-of-k multisig flows: each party
calls add_witness on the previous party's output cbor; any party
submits via wallet.submit_signed_tx.
mcp tools: 10 → 12.
- wallet.mint.cip68_nft — args: user_address, name_body_hex (≤28b),
metadata (json object), user_lovelace? ref_address? ref_lovelace?
invalid_after_slot? — defaults provided for the ergonomic case
(ref_addr=wallet, lovelace=1.5 ADA each).
- wallet.sign_partial — args: cbor_hex — appends our witness, returns
updated hex. usable for MAP treasury 2-of-2 once a
wallet.mint.unsigned-with-policy-arg lands (TODO, deferred).
65 → 79 unit tests. cip68 module: 9 tests covering prefix+datum
shape. sign module: 4 tests covering one-witness, two-witness,
body-hash invariant, garbage rejection. integration test in mint
verifies cip68 build produces 3 outputs with inline datum on the
ref output.
unblocks named mints. wallet.mint now accepts an optional `metadata`
arg (json object); explorers + wallets render the asset with name/image
instead of <asset1xyz...>.
new aldabra-core::metadata module:
- json_to_metadatum: serde_json::Value → Metadatum (recursive). numbers
must fit i64 (cardano metadata Int width). strings >64 bytes split
into Array<Text> chunks at utf-8 char boundaries (CIP-25 v2
long-string convention). null is rejected.
- build_cip25_aux_data(policy_id_hex, asset_name_hex, json_value):
builds the label-721 wrapper (Map { 721: Map { policy_bytes:
Map { name_bytes: attrs }, "version": "2.0" } }), wraps in
AuxiliaryData::PostAlonzo, returns cbor bytes.
mint module:
- new build_signed_mint_with_metadata + build_unsigned_mint now take
optional cip25_metadata. backward-compat: build_signed_mint is a
thin no-metadata wrapper.
- prepare_mint + build_mint_staging plumb aux_data_cbor through.
staging.auxiliary_data(bytes) is the new fork API surface — when
set, conway::build_conway_raw decodes + computes
auxiliary_data_hash automatically.
- regression test build_signed_mint_with_metadata_produces_aux_hash:
decodes the resulting signed cbor, asserts both
body.auxiliary_data_hash is Some and tx.auxiliary_data is present.
catches the failure mode where metadata is silently dropped.
mcp wallet.mint gains a `metadata` arg field surfaced via schemars
JsonSchema. tools/list shape correctly carries the optional json
object.
depends on Sulkta-Coop/pallas@feat-aux-data — vendored via
[patch.crates-io] in the workspace Cargo.toml. PR upstream pending.
56 → 65 unit tests. 8 → 8 mcp tools (count unchanged, wallet.mint
gained an arg).
new aldabra-core::mint module:
- PolicySpec enum: SingleSig, SingleSigTimelock, NofK
- SingleSig{pkh}: ScriptPubkey native script
- SingleSigTimelock{pkh, slot}: ScriptAll[ScriptPubkey, InvalidHereafter(slot)]
- NofK{n, [pkhs]}: ScriptNOfK
- PolicySpec::single_sig(payment) + single_sig_timelock(payment, slot)
convenience constructors that derive the pkh from a PaymentKey.
- policy_id() = pallas_traverse::ComputeHash<28>::compute_hash, which
is blake2b-224 of (0x00 || cbor) — the canonical native-script hash.
- to_cbor() for callers that want the script bytes raw.
build_signed_mint / build_unsigned_mint:
- two-pass fee like the send path, plus a few extras specific to mint:
staging.mint_asset(policy, name, qty), .script(Native, cbor),
.disclosed_signer(payment_pkh) — the disclosed_signer surfaces the
required signature in the tx body so the chain knows which witness
to verify against the script.
- positive qty mints (asset goes into dest output), negative qty burns
(asset comes out of input holdings, change preserves leftover).
- token-bearing change must hold ≥ min_utxo lovelace — same guard as
the send path.
mcp tools:
- wallet.policy.create — args: invalid_after_slot? — returns
{policy_id_hex, script_cbor_hex, type}.
- wallet.mint — args: dest_address, dest_lovelace (≥ 1 ADA),
asset_name_hex, quantity (i64), invalid_after_slot? — auto-generates
a single-sig policy bound to the wallet's payment key, builds, signs,
submits.
8 → 10 mcp tools. 48 → 56 unit tests.
3.2 (CIP-25 metadata) is BLOCKED on pallas-txbuilder 0.32/0.35 — both
hardcode `auxiliary_data: None` in the conway builder. options for next
session: (a) post-build CBOR injection, (b) assemble tx via
pallas-primitives directly, (c) wait for upstream. flagged in the
spec doc.
3.3 (CIP-68) depends on 3.2. 3.6 (MAP 2-of-2) needs the multi-key
signing flow on the build side; PolicySpec::NofK variant is ready but
build_signed_mint only sign with one key today.
InputUtxo gains an `assets: BTreeMap<String, u64>` field matching
aldabra-chain::Utxo's shape (`policy_id_hex(56) || asset_name_hex`
key). new AssetSpec type for the recipient asset list.
asset-aware select_utxos:
- phase 1: per-asset greedy by holding size, pulls UTXOs containing
each requested asset until coverage ≥ target
- phase 2: ada-only greedy to top up lovelace need
this preserves the prior ada-only behavior when assets list is empty.
build_signed_payment_with_assets / build_unsigned_payment_with_assets
build outputs with .add_asset() for each requested + each leftover
(change-side). guards: token-bearing change must hold ≥ min_utxo
ADA — surfaced as a clearer error than letting the chain reject a
sub-min output.
cold-sign flow (phase 2.6):
- new tools wallet.send.unsigned (returns {cbor_hex, summary} json
for human review + cold-signer consumption) and
wallet.submit_signed_tx (takes hex-encoded signed cbor → submit).
- PaymentSummary now carries send_assets + change_assets vecs so the
human reviewer can spot accidental token transfers.
- summary.tx_hash is the predicted body hash; signed CBOR will hash
to the same value (signature is over the body, not the cbor wrapper).
helpers: hex_encode/decode, parse_policy_id, parse_asset_name,
split_asset_key. mcp side defines its own McpAssetSpec with
schemars::JsonSchema derive so the schemars dep doesn't bleed into
the security-boundary core crate.
48 unit tests (was 41). new coverage: asset-aware selection (greedy +
missing-asset error), policy/asset-name parsers, multi-asset cbor
build, change-asset summary correctness.
phase 2.7 (live preprod smoke against funded wallet) procedure
documented in memory/spec-aldabra-buildout.md; needs cobb's faucet ada.
end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).
deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.
new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs
caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.
headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.
dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.
29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.
phase 2 (send) is next.
Repo skeleton for sulkta-wallet, the rust-native cardano lite wallet
with MCP server interface. Builds end-to-end, types in place,
real cardano primitives land next pass.
Crates:
wallet-core — pure crypto + types. mnemonic, key derivation,
signing. No I/O. Security boundary.
wallet-chain — pluggable backends. ChainBackend trait, Koios
client (stub for now). Ogmios + submit in phase 2.
wallet-mcp — the binary. stdio MCP transport via rmcp.
Phase plan in ROADMAP.md, threat model in docs/architecture.md.
This is also Cobb's first Rust project + a real-world workout for
crafting-table's rust toolchain.