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 Sulkta before starting.
This commit is contained in:
Sulkta 2026-05-06 07:08:08 -07:00
parent e4830ef250
commit 77d4b2d0b5
4 changed files with 763 additions and 2 deletions

View file

@ -358,6 +358,35 @@ fn default_register_first() -> bool {
true
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct VoteDelegateArgs {
/// DRep target. One of:
/// - bech32 DRep ID (e.g. "drep1abc..." or "drep_script1abc...")
/// - "abstain" — predefined always-abstain DRep
/// - "no_confidence" — predefined no-confidence DRep
pub drep: String,
/// If true, prepends a stake-registration certificate (one-time
/// 2 ADA deposit). Set false if already registered.
#[serde(default = "default_register_first")]
pub register_first: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DrepRegisterArgs {
/// Optional CIP-100/119 anchor URL (off-chain DRep metadata).
#[serde(default)]
pub anchor_url: Option<String>,
/// 64-char hex blake2b-256 of the off-chain anchor content. Both
/// anchor_url and anchor_data_hash_hex must be set or both omitted.
#[serde(default)]
pub anchor_data_hash_hex: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DrepDeregisterArgs {
// No args — uses the wallet's stake credential.
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TxSummaryArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, partially-signed,
@ -1053,6 +1082,159 @@ impl WalletService {
Ok(tx_hash)
}
#[tool(
name = "wallet_vote_delegate",
description = "Conway: delegate this wallet's voting power to a DRep. Args: drep (bech32 'drep1...' / 'drep_script1...' / 'abstain' / 'no_confidence'), register_first (bool, default true — adds 2 ADA stake-registration cert if needed). Signs with payment + stake keys, submits, returns tx hash."
)]
async fn wallet_vote_delegate(
&self,
#[tool(aggr)] VoteDelegateArgs {
drep,
register_first,
}: VoteDelegateArgs,
) -> Result<String, String> {
let target = aldabra_core::governance::parse_drep_target(&drep)
.map_err(|e| format!("parse drep: {e}"))?;
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_vote_delegation(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
target,
register_first,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign vote delegation: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_drep_register",
description = "Conway: register this wallet's stake credential as a DRep. Costs the protocol-defined 500 ADA deposit (refunded on deregistration). Args: anchor_url (optional CIP-100/119 metadata URL), anchor_data_hash_hex (optional 64-char blake2b-256 of anchor content). Both anchor fields must be set or both omitted. Returns submitted tx hash."
)]
async fn wallet_drep_register(
&self,
#[tool(aggr)] DrepRegisterArgs {
anchor_url,
anchor_data_hash_hex,
}: DrepRegisterArgs,
) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_drep_registration(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
anchor_url.as_deref(),
anchor_data_hash_hex.as_deref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign drep register: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_drep_deregister",
description = "Conway: deregister this wallet's DRep, refunding the 500 ADA deposit. No args. Returns submitted tx hash."
)]
async fn wallet_drep_deregister(
&self,
#[tool(aggr)] _args: DrepDeregisterArgs,
) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — need at least one to fund the fee",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_drep_deregistration(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign drep deregister: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_mint_unsigned",
description = "Build a mint TX without signing — for cold-sign or multi-sig flows. Args: dest_address, dest_lovelace, asset_name_hex, quantity, policy (optional, defaults to wallet single-sig; pass {type:'nofk',n:2,signer_pkhs_hex:[..]} for multi-sig treasury), metadata (optional CIP-25), disclosed_signer_pkh_hex (optional, defaults to wallet's pkh). Returns JSON {cbor_hex, summary}. Pass through wallet_sign_partial chain, then wallet_submit_signed_tx."
@ -2808,7 +2990,7 @@ impl ServerHandler for WalletService {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
instructions: Some(
"aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
"aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate), Conway governance (wallet_vote_delegate to a DRep, wallet_drep_register / wallet_drep_deregister for becoming a DRep). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
),
..Default::default()
}