Without language_view, pallas does not compute script_data_hash on
the tx body. Plutus txs without script_data_hash get rejected with
ConwayUtxowFailure (PPViewHashesDontMatch SNothing (SJust ...)).
Caught 2026-05-07 attempting governor bootstrap on preprod against
Agora's V2 GST policy. Previous code only set language_view when
the policy was V3 — every V2 mint hit the chain rejection.
Three changes:
1. crates/aldabra-core/src/plutus_cost_models.rs — append
PLUTUS_V2_COST_MODEL_PREPROD constant (175 i64 entries), pulled
live from preprod Koios epoch_params 2026-05-07. Same protocol-
version convention as the existing V3 constant: V2 cost model
is identical mainnet vs preprod (cost models are protocol-version
parameters, not network), so the _PREPROD suffix is naming
convention, not a separation point.
2. crates/aldabra-core/src/plutus_mint.rs — replace the V3-only
language_view block with a per-PlutusVersion match. V2 wires
the new constant; V3 keeps the existing
params.plutus_v3_cost_model path; V1 left as TODO with a note
(no V1 mint use case yet).
3. crates/aldabra-dao/examples/dump_governor.rs — small cargo
example that encodes a sample GovernorDatum to CBOR hex via
the existing aldabra_dao::agora::GovernorDatum::to_plutus_data
path. Used during preprod DAO bringup to construct the inline
datum for the governor bootstrap tx. Edit values + re-run for
any DAO bringup. Builds against the existing pallas-codec
dev-dependency.
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
Adds aldabra-core::plutus_mint with build_signed_plutus_mint and
build_unsigned_plutus_mint. Designed for Agora-style DAO bringup
where every "mint a single ST token under a Plutus policy and
deposit it at a script address with inline datum" tx shares the
same shape (governor / stake / proposal bootstrap).
PlutusMintArgs takes:
- required_inputs: UTxOs that MUST be spent (e.g. gstOutRef the
GST policy is parameterized on)
- policy_cbor + policy_version + redeemer + ex_units
- mint_assets: list of (asset_name_hex, qty) under this policy
- dest_address + dest_lovelace + dest_extra_assets to forward
+ optional inline datum
Same collateral/funding pattern as plutus.rs::build_signed_plutus_spend
(smallest ADA-only ≥ 5 ADA for collateral, separate funding picks
to cover dest + fee + min_change). PlutusV3 cost-model wired into
language_view per the existing PLUTUS-4 fix.
3 unit tests cover empty-policy / empty-mint / required-input-not-
in-available rejections + the governor-bootstrap shape produces
valid Conway CBOR.
This is Phase 2 of the Track B-fast preprod DAO bringup. Together
with the kayos/wallet-ref-script Phase 1 commits (b9124ee + a65ab78)
it gives aldabra everything needed to deploy 11 Agora script ref
UTxOs + bootstrap a governor + bootstrap stakes on preprod.
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.
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.
The pallas patch in [patch.crates-io] is now ssh://git@gitea after the
2026-05-06 token-scrub. Inside a docker build the rust container has no
SSH key and no known_hosts for gitea, so cargo's libgit2 / system-git
both reject the fetch.
Mount /root/.git-credentials as a BuildKit secret (mode=0400, required)
and set a build-time `url.HTTP.insteadOf SSH` rewrite. Cargo.toml and
Cargo.lock keep their SSH URLs — the rewrite is git-CLI-level so no
credential ever lands in the lock file or in any image layer.
Build invocation:
docker build --secret id=git_credentials,src=<creds-file> ...
where <creds-file> is one line `http://USER:PAT@192.168.0.5:3001`.
This mirrors the pattern crafting-table already uses on its runner
(.git-credentials + url.insteadOf rewrite). nightly-builds.sh on Lucy
will need an analogous --secret arg before it can rebuild this branch.
## Type port effects_raw → EffectsMap (4c-bis-1)
Replace ProposalDatum.effects_raw: PlutusData with typed
effects: EffectsMap. Required precondition for the upcoming
proposal_mint_gats builder — every downstream piece reads typed
effects, not opaque bytes.
- ProposalEffectMetadata { datum_hash: 32 bytes, script_hash: Option<28 bytes> }
ProductIsData → CBOR Array. Field order matches Agora.Proposal.hs:296.
Maybe ScriptHash → Constr 0 [bytes]/Constr 1 [].
- EffectsMap: Vec<(ResultTag i64, Vec<(ScriptHash, ProposalEffectMetadata)>)>.
Encoded as nested PlutusData::Map. Keys preserved in insertion
order (Plutus map equality is set-like, but validator builds the
expected datum in same order so we stay consistent).
- EffectsMap::info_only(&[0, 1]) helper for the proposal_create case
(every result_tag → empty inner map). Replaces the hand-rolled
PlutusData::Map.
- has_neutral_effect() + keys() helpers for validator preflight checks.
Live-decode test (decodes_sulkta_live_proposal_zero) tightened:
Sulkta #0 is NOT pure InfoOnly — tag 1 has a real effect targeting
script hash 92b7..96f with datum_hash 046dff..e83c (no auth-script
wrapper). Tag 0 is empty so phasNeutralEffect still passes. Test
asserts the full typed shape now + round-trips via decode↔encode.
All 4 builders' fixtures updated: effects_raw: constr(0, vec![]) →
effects: EffectsMap::info_only(&[0, 1]). Unused constr/PlutusData/
KeyValuePairs imports pruned.
## DaoConfig GAT policy fields (4c-bis-2)
- DaoConfig.gat_policy: Option<String> (56 hex)
- ScriptRefs.gat_policy_ref: Option<String> (txhash#index)
- DaoConfig::validate now checks gat_policy + stake_st_policy +
proposal_st_policy are 56 hex chars when set
- All DaoConfig fixtures updated with gat_policy: None
- DaoRegisterArgs gains gat_policy + gat_policy_ref optional fields
- dao_show output includes the new fields automatically (serde)
Sulkta-specific note: gat_policy hash isn't observable on chain
yet (no MintGATs tx has fired). Hand-populate from MLabs deployment
record when ready, or compute from the deployed governor's CBOR
parameters.
The first cut asserted byte-exact CBOR round-trip, but pallas-codec
emits def-arrays (`81`) while chain CBOR uses indef (`9f...ff`).
Both are Plutus-structurally-equal — validator's `==` accepts
either — but Vec<u8> equality doesn't. Switch to assert
`decode(reencode(decode(cbor)))` equals `decode(cbor)` instead.
That's the actual validator-relevant invariant: typed fields
preserved, no silent drift.
## Multi-net slot↔ms (drops mainnet-only gate)
Replaces mainnet_slot_to_posix_ms with slot_to_posix_ms(network, slot)
that handles all three Cardano networks. Vote + advance MCP tools no
longer hard-error on preprod/preview.
Per-network Shelley HF constants (slot, posix_ms_at_slot):
- mainnet: 4_492_800 1_596_059_091_000 (2020-07-29 21:44:51 UTC)
- preprod: 86_400 1_655_769_600_000 (2022-06-21 00:00 UTC)
- preview: 0 1_666_656_000_000 (2022-10-25 00:00 UTC)
Pre-Shelley (Byron) slots still rejected — they had different lengths
and DAO operations don't need them. Routed via cfg.network at every
call site so the MCP tool's behavior follows the active DAO's chain.
## Live-decode StakeDatum regression tests
Anchors the StakeDatum type port to two real on-chain UTxOs at the
Sulkta stakes_addr:
- decodes_sulkta_live_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).
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.
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.
Per rescope 2026-05-06: real code repos get SSH for git auth, no
embedded credentials in URLs at all. Companion to commit a3a8421
which dropped the embedded token; this drops the HTTP transport
in favor of pure SSH key auth.
- Cargo.toml [patch.crates-io] URLs now ssh://git@192.168.0.5:23/...
- Cargo.lock source URLs match.
- .cargo/config.toml [net] git-fetch-with-cli = true unchanged —
still required so cargo defers to system git, which uses the
configured SSH identity.
Hosts that build aldabra need:
- /root/.ssh/config alias 'gitea' → 192.168.0.5:23, IdentityFile
pointing at a key registered to the kayos Gitea account
- The corresponding private key
The ed25519 key generated for this is at
/root/.openclaw/keys/id_ed25519_kayos_gitea on the dev box. Pubkey
registered on kayos's Gitea account 2026-05-06. The crafting-table
runner on Lucy still uses an HTTP credential helper for now — that's
operational state in a kayos-controlled container, allowed under
the rescope. Will migrate to SSH later if Cobb wants full parity.
Hard rule from Cobb 2026-05-06: zero secrets hardcoded in committed
source. The [patch.crates-io] block had the kayos Gitea PAT embedded
in the URL, which cargo then duplicated into Cargo.lock's source URLs.
Fix:
- Cargo.toml [patch.crates-io] URLs are now tokenless
(http://192.168.0.5:3001/...)
- Cargo.lock source URLs scrubbed to match
- .cargo/config.toml adds [net] git-fetch-with-cli = true so cargo
defers to system git for fetches; system git authenticates via
the user's git credential helper (~/.git-credentials chmod 600).
Operators (devs + crafting-table runner) need a working git credential
helper for the LAN Gitea, configured out-of-band (NOT in this repo).
Pattern: `git config --global credential.helper store` +
`echo http://USER:TOKEN@192.168.0.5:3001 > ~/.git-credentials &&
chmod 600 ~/.git-credentials`. After Cobb rotates the kayos PAT,
update that file on every host that builds aldabra.
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.
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.