diff --git a/crates/aldabra-core/src/governance.rs b/crates/aldabra-core/src/governance.rs new file mode 100644 index 0000000..32dca2f --- /dev/null +++ b/crates/aldabra-core/src/governance.rs @@ -0,0 +1,573 @@ +//! Conway-era governance flows. +//! +//! Phase 5 of the aldabra roadmap. Surfaces: +//! +//! - [`build_signed_vote_delegation`] — delegate the wallet's stake +//! credential's voting power to a DRep (key, script, abstain, or +//! no-confidence). +//! - [`build_signed_drep_registration`] — register the wallet's stake +//! credential as a key-based DRep (deposit-bearing). +//! - [`build_signed_drep_deregistration`] — return the deposit, retire +//! the DRep. +//! +//! These mirror [`crate::stake::build_signed_stake_delegation`]'s shape: +//! two-pass fee, dual-witness (payment + stake) signing, change with +//! input-asset preservation. Difference from pool delegation is just +//! which `Certificate` enum variant we encode. +//! +//! ## What's NOT here +//! +//! - **DRep update** (`UpdateDRepCert`) — anchor-only; refresh metadata. +//! Easy to add when needed; same shape as registration without deposit. +//! - **Vote casting** (`VotingProcedure` + `voting_procedures` field on +//! the tx body) — Phase 6. Requires extending the Sulkta-Coop/pallas +//! fork to thread `voting_procedures` through `StagingTransaction` +//! (currently TODO at `pallas-txbuilder/src/conway.rs:254`). +//! - **Committee certs** (`AuthCommitteeHot`, `ResignCommitteeCold`) — +//! not needed for any current Sulkta use case. + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_primitives::conway::{Certificate, DRep, StakeCredential}; +use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; + +use crate::sign::add_witness; +use crate::tx::InputUtxo; +use crate::{Network, PaymentKey, ProtocolParams, StakeKey, WalletError}; + +/// Conway DRep registration deposit. Mainnet protocol parameter +/// `drep_deposit` is currently 500 ADA. Caller can pass an override +/// via `params` if a hardfork changes it; default constant here. +pub const DREP_REGISTRATION_DEPOSIT_LOVELACE: u64 = 500_000_000; + +/// Two witnesses (payment + stake) — same overhead as +/// `stake::build_signed_stake_delegation`. +const TWO_WITNESS_OVERHEAD_BYTES: u64 = 256; + +/// Where to delegate voting power. Mirrors `pallas_primitives::conway::DRep` +/// but parses friendlier inputs (bech32 drep_id, "abstain", "no_confidence") +/// at the call site. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DRepTarget { + /// 28-byte key hash (DRep is registered against an AddrKeyhash). + Key(Hash<28>), + /// 28-byte script hash (DRep is registered against a ScriptHash). + Script(Hash<28>), + /// Predefined "always abstain" DRep — passes through to `DRep::Abstain`. + Abstain, + /// Predefined "no confidence" DRep — passes through to `DRep::NoConfidence`. + NoConfidence, +} + +impl DRepTarget { + fn into_pallas(self) -> DRep { + match self { + DRepTarget::Key(h) => DRep::Key(h), + DRepTarget::Script(h) => DRep::Script(h), + DRepTarget::Abstain => DRep::Abstain, + DRepTarget::NoConfidence => DRep::NoConfidence, + } + } +} + +/// Parse a `drep1...` bech32 DRep ID into a [`DRepTarget`]. +/// +/// `drep1...` IDs are CIP-129 conway-era. The hrp + first byte signals +/// key-vs-script. See https://cips.cardano.org/cip/CIP-0129/. +/// +/// Special strings: +/// - `"abstain"` → `DRepTarget::Abstain` +/// - `"no_confidence"` → `DRepTarget::NoConfidence` +pub fn parse_drep_target(s: &str) -> Result { + use bech32::FromBase32; + if s == "abstain" { + return Ok(DRepTarget::Abstain); + } + if s == "no_confidence" { + return Ok(DRepTarget::NoConfidence); + } + let (hrp, data, _) = bech32::decode(s) + .map_err(|e| WalletError::Address(format!("bad drep bech32: {e}")))?; + if hrp != "drep" && hrp != "drep_script" { + return Err(WalletError::Address(format!( + "expected drep / drep_script hrp, got '{hrp}'" + ))); + } + let bytes: Vec = Vec::::from_base32(&data) + .map_err(|e| WalletError::Address(format!("bad drep base32: {e}")))?; + // CIP-129: first byte's low nibble carries the credential type: + // 0x22 = key DRep, 0x23 = script DRep. Strip the header byte if present + // (CIP-129 IDs are 29 bytes total: 1 header + 28 hash). + let hash_bytes = if bytes.len() == 29 { + // CIP-129 with header byte. + let hdr = bytes[0]; + let kind = hdr & 0x0F; + let mut arr = [0u8; 28]; + arr.copy_from_slice(&bytes[1..]); + let h = Hash::<28>::new(arr); + return Ok(match kind { + 0x2 => DRepTarget::Key(h), + 0x3 => DRepTarget::Script(h), + other => { + return Err(WalletError::Address(format!( + "unknown DRep credential kind 0x{:x} in CIP-129 header", + other + ))); + } + }); + } else { + bytes + }; + if hash_bytes.len() != 28 { + return Err(WalletError::Address(format!( + "drep hash must be 28 bytes (or 29 with CIP-129 header), got {}", + hash_bytes.len() + ))); + } + let mut arr = [0u8; 28]; + arr.copy_from_slice(&hash_bytes); + let h = Hash::<28>::new(arr); + // Without CIP-129 header, infer from hrp. + Ok(match hrp.as_str() { + "drep" => DRepTarget::Key(h), + "drep_script" => DRepTarget::Script(h), + _ => unreachable!(), + }) +} + +fn parse_address(bech32: &str) -> Result { + pallas_addresses::Address::from_bech32(bech32) + .map_err(|e| WalletError::Address(e.to_string())) +} + +fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { + if hex_str.len() != 64 { + return Err(WalletError::Derivation(format!( + "expected 64-char hex tx_hash, got {}", + hex_str.len() + ))); + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) + .map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?; + } + Ok(Hash::<32>::new(out)) +} + +fn network_id_for(network: Network) -> u8 { + match network { + Network::Mainnet => 1, + Network::Preview | Network::Preprod => 0, + } +} + +/// Build + sign a vote-delegation tx. If `register_first` is true, +/// prepends a `StakeRegistration` certificate (one-time, costs 2 ADA +/// deposit) — same shape as `build_signed_stake_delegation`. +#[allow(clippy::too_many_arguments)] +pub fn build_signed_vote_delegation( + payment_key: &PaymentKey, + stake_key: &StakeKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + drep_target: DRepTarget, + register_first: bool, + params: &ProtocolParams, +) -> Result, WalletError> { + let stake_pkh = stake_key.public_key_hash(); + let credential = StakeCredential::AddrKeyhash(stake_pkh); + + let mut cert_bytes_list: Vec> = Vec::new(); + if register_first { + let reg = Certificate::StakeRegistration(credential.clone()); + cert_bytes_list.push( + minicbor::to_vec(®) + .map_err(|e| WalletError::Derivation(format!("encode reg cert: {e}")))?, + ); + } + let deleg = Certificate::VoteDeleg(credential, drep_target.into_pallas()); + cert_bytes_list.push( + minicbor::to_vec(&deleg) + .map_err(|e| WalletError::Derivation(format!("encode vote-deleg cert: {e}")))?, + ); + + // Stake-registration deposit only (vote_delegation itself has no deposit). + let deposit = if register_first { + crate::stake::STAKE_KEY_DEPOSIT_LOVELACE + } else { + 0 + }; + + sign_cert_tx( + payment_key, + stake_key, + network, + available_utxos, + change_address_bech32, + cert_bytes_list, + deposit, + params, + ) +} + +/// Build + sign a DRep registration tx. The wallet's stake credential +/// becomes a key-based DRep with a 500 ADA deposit (default; pulled +/// from `params` if the protocol changes). +/// +/// `anchor_url` + `anchor_data_hash_hex` are optional — if set, attach +/// CIP-100/119 metadata to the registration. Pass `None` for both when +/// you don't have an off-chain anchor yet. +#[allow(clippy::too_many_arguments)] +pub fn build_signed_drep_registration( + payment_key: &PaymentKey, + stake_key: &StakeKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + anchor_url: Option<&str>, + anchor_data_hash_hex: Option<&str>, + params: &ProtocolParams, +) -> Result, WalletError> { + use pallas_codec::utils::Nullable; + use pallas_primitives::conway::Anchor; + + let stake_pkh = stake_key.public_key_hash(); + let drep_credential = StakeCredential::AddrKeyhash(stake_pkh); + + let anchor: Nullable = match (anchor_url, anchor_data_hash_hex) { + (Some(url), Some(hash_hex)) => { + if hash_hex.len() != 64 { + return Err(WalletError::Derivation(format!( + "anchor_data_hash must be 64-char hex, got {}", + hash_hex.len() + ))); + } + let mut h_arr = [0u8; 32]; + for i in 0..32 { + h_arr[i] = u8::from_str_radix(&hash_hex[i * 2..i * 2 + 2], 16).map_err(|_| { + WalletError::Derivation("invalid hex in anchor_data_hash".into()) + })?; + } + Nullable::Some(Anchor { + url: url.to_string(), + content_hash: Hash::<32>::new(h_arr), + }) + } + (None, None) => Nullable::Null, + _ => { + return Err(WalletError::Derivation( + "anchor_url and anchor_data_hash must both be set or both omitted".into(), + )); + } + }; + + let cert = Certificate::RegDRepCert(drep_credential, DREP_REGISTRATION_DEPOSIT_LOVELACE, anchor); + let cert_bytes = minicbor::to_vec(&cert) + .map_err(|e| WalletError::Derivation(format!("encode RegDRep cert: {e}")))?; + + sign_cert_tx( + payment_key, + stake_key, + network, + available_utxos, + change_address_bech32, + vec![cert_bytes], + DREP_REGISTRATION_DEPOSIT_LOVELACE, + params, + ) +} + +/// Build + sign a DRep deregistration tx. Returns the 500 ADA deposit +/// to the wallet. +#[allow(clippy::too_many_arguments)] +pub fn build_signed_drep_deregistration( + payment_key: &PaymentKey, + stake_key: &StakeKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + params: &ProtocolParams, +) -> Result, WalletError> { + let stake_pkh = stake_key.public_key_hash(); + let drep_credential = StakeCredential::AddrKeyhash(stake_pkh); + let cert = Certificate::UnRegDRepCert(drep_credential, DREP_REGISTRATION_DEPOSIT_LOVELACE); + let cert_bytes = minicbor::to_vec(&cert) + .map_err(|e| WalletError::Derivation(format!("encode UnRegDRep cert: {e}")))?; + + // Negative deposit — we get it back. Two-pass fee accounts for it + // by leaving `deposit` at 0 here and letting the wallet output absorb + // the refund. Note: pallas-txbuilder writes the deposit as a negative + // contribution implicitly via the cert; the change calc here just + // needs to know we DON'T owe the protocol anything. Caller should + // expect their wallet output to grow by 500 ADA - fee. + sign_cert_tx_with_refund( + payment_key, + stake_key, + network, + available_utxos, + change_address_bech32, + vec![cert_bytes], + DREP_REGISTRATION_DEPOSIT_LOVELACE, + params, + ) +} + +/// Shared cert-tx signing: builds 2-pass-fee, dual-witness, ada-only-funded +/// tx with input asset preservation on change. +#[allow(clippy::too_many_arguments)] +fn sign_cert_tx( + payment_key: &PaymentKey, + stake_key: &StakeKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + cert_bytes_list: Vec>, + deposit: u64, + params: &ProtocolParams, +) -> Result, WalletError> { + let change_addr = parse_address(change_address_bech32)?; + let network_id = network_id_for(network); + + let fee_pass1: u64 = 500_000; + let need = deposit + .checked_add(fee_pass1) + .and_then(|x| x.checked_add(params.min_utxo_lovelace)) + .ok_or_else(|| WalletError::Derivation("amount overflow".into()))?; + + let mut sorted: Vec = available_utxos.to_vec(); + sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace)); + let mut acc: u64 = 0; + let mut selected: Vec = Vec::new(); + for u in sorted { + acc = acc.saturating_add(u.lovelace); + selected.push(u); + if acc >= need { + break; + } + } + if acc < need { + return Err(WalletError::Derivation(format!( + "insufficient funds: need {need} (deposit+fee+min_change), have {acc}" + ))); + } + let total_in: u64 = selected.iter().map(|u| u.lovelace).sum(); + + let mut input_assets: std::collections::BTreeMap = Default::default(); + for u in &selected { + for (k, v) in &u.assets { + let entry = input_assets.entry(k.clone()).or_insert(0); + *entry = entry.saturating_add(*v); + } + } + + let build_with_fee = |fee: u64, + change_lovelace: u64| + -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); + } + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &input_assets { + if *q == 0 { + continue; + } + if k.len() < 56 { + return Err(WalletError::Derivation("asset key shorter than 56 chars".into())); + } + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let mut policy_bytes = [0u8; 28]; + for i in 0..28 { + policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16) + .map_err(|_| WalletError::Derivation("invalid policy hex".into()))?; + } + let policy = Hash::<28>::new(policy_bytes); + let mut name_bytes = Vec::with_capacity(name_hex.len() / 2); + for i in (0..name_hex.len()).step_by(2) { + name_bytes.push( + u8::from_str_radix(&name_hex[i..i + 2], 16) + .map_err(|_| WalletError::Derivation("invalid name hex".into()))?, + ); + } + change_out = change_out + .add_asset(policy, name_bytes, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + } + staging = staging.output(change_out); + for cb in &cert_bytes_list { + staging = staging.add_certificate(cb.clone()); + } + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; + + let change_pass1 = total_in + .checked_sub(deposit + fee_pass1) + .ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?; + let staging1 = build_with_fee(fee_pass1, change_pass1)?; + let unsigned = staging1 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))? + .tx_bytes + .0; + let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES; + let real_fee = params.min_fee_for_size(est_signed); + + let token_change = !input_assets.is_empty(); + let final_change = total_in + .checked_sub(deposit + real_fee) + .ok_or_else(|| WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}" + )))?; + if final_change < params.min_utxo_lovelace && token_change { + return Err(WalletError::Derivation(format!( + "insufficient ADA for token-bearing change: change={final_change}, min={}", + params.min_utxo_lovelace + ))); + } + if final_change < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "change after deposit+fee ({final_change}) below min utxo ({}). top up the wallet.", + params.min_utxo_lovelace + ))); + } + + let staging2 = build_with_fee(real_fee, final_change)?; + let built = staging2 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?; + + let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?; + let stake_payment_proxy = crate::stake::stake_key_as_payment_proxy(stake_key); + let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?; + Ok(fully_signed) +} + +/// Same as `sign_cert_tx` but for refund-bearing certs (deregistrations). +/// The deposit is added back to the change instead of subtracted. +#[allow(clippy::too_many_arguments)] +fn sign_cert_tx_with_refund( + payment_key: &PaymentKey, + stake_key: &StakeKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + cert_bytes_list: Vec>, + refund: u64, + params: &ProtocolParams, +) -> Result, WalletError> { + let change_addr = parse_address(change_address_bech32)?; + let network_id = network_id_for(network); + + let fee_pass1: u64 = 500_000; + // We need just fee + min_change; refund covers the rest. + let need = fee_pass1 + .checked_add(params.min_utxo_lovelace) + .ok_or_else(|| WalletError::Derivation("amount overflow".into()))?; + + let mut sorted: Vec = available_utxos.to_vec(); + sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace)); + let mut acc: u64 = 0; + let mut selected: Vec = Vec::new(); + for u in sorted { + acc = acc.saturating_add(u.lovelace); + selected.push(u); + if acc >= need { + break; + } + } + if acc < need { + return Err(WalletError::Derivation(format!( + "insufficient funds: need {need} (fee+min_change), have {acc}" + ))); + } + let total_in: u64 = selected.iter().map(|u| u.lovelace).sum(); + + let build_with_fee = |fee: u64, change_lovelace: u64| -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); + } + let change_out = Output::new(change_addr.clone(), change_lovelace); + staging = staging.output(change_out); + for cb in &cert_bytes_list { + staging = staging.add_certificate(cb.clone()); + } + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; + + // Change includes the refund: change = total_in + refund - fee. + let change_pass1 = total_in + .checked_add(refund) + .and_then(|x| x.checked_sub(fee_pass1)) + .ok_or_else(|| WalletError::Derivation("pass1: amount overflow".into()))?; + let staging1 = build_with_fee(fee_pass1, change_pass1)?; + let unsigned = staging1 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))? + .tx_bytes + .0; + let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES; + let real_fee = params.min_fee_for_size(est_signed); + + let final_change = total_in + .checked_add(refund) + .and_then(|x| x.checked_sub(real_fee)) + .ok_or_else(|| WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} refund={refund} fee={real_fee}" + )))?; + if final_change < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "change ({final_change}) below min utxo ({})", + params.min_utxo_lovelace + ))); + } + + let staging2 = build_with_fee(real_fee, final_change)?; + let built = staging2 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?; + + let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?; + let stake_payment_proxy = crate::stake::stake_key_as_payment_proxy(stake_key); + let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?; + Ok(fully_signed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_drep_target_handles_named_specials() { + assert_eq!(parse_drep_target("abstain").unwrap(), DRepTarget::Abstain); + assert_eq!( + parse_drep_target("no_confidence").unwrap(), + DRepTarget::NoConfidence + ); + } + + #[test] + fn parse_drep_target_rejects_garbage() { + assert!(parse_drep_target("not-a-drep").is_err()); + assert!(parse_drep_target("pool1abc").is_err()); + } + + #[test] + fn drep_target_into_pallas_round_trip() { + let h = Hash::<28>::new([0u8; 28]); + assert!(matches!(DRepTarget::Key(h).into_pallas(), DRep::Key(_))); + assert!(matches!(DRepTarget::Script(h).into_pallas(), DRep::Script(_))); + assert!(matches!(DRepTarget::Abstain.into_pallas(), DRep::Abstain)); + assert!(matches!( + DRepTarget::NoConfidence.into_pallas(), + DRep::NoConfidence + )); + } +} diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index c221c7a..b48ddc2 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -37,6 +37,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; pub mod cip68; pub mod derive; +pub mod governance; pub mod inspect; pub mod metadata; pub mod mint; @@ -62,6 +63,11 @@ pub use plutus::{ PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE, }; pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE}; +pub use governance::{ + build_signed_drep_deregistration, build_signed_drep_registration, + build_signed_vote_delegation, parse_drep_target, DRepTarget, + DREP_REGISTRATION_DEPOSIT_LOVELACE, +}; pub use tx::{ build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary, diff --git a/crates/aldabra-core/src/stake.rs b/crates/aldabra-core/src/stake.rs index 0b11599..8bdd892 100644 --- a/crates/aldabra-core/src/stake.rs +++ b/crates/aldabra-core/src/stake.rs @@ -258,7 +258,7 @@ pub fn build_signed_stake_delegation( /// body-hash signing logic is identical regardless of which key /// "role" the wallet considers the XPrv. Crate-internal helper — /// callers use `build_signed_stake_delegation` end-to-end. -fn stake_key_as_payment_proxy(stake_key: &StakeKey) -> PaymentKey { +pub(crate) fn stake_key_as_payment_proxy(stake_key: &StakeKey) -> PaymentKey { crate::derive::PaymentKey::from_xprv(stake_key.xprv().clone()) } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 5c14a3c..104b38a 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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, + /// 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, +} + +#[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 { + 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 = 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 { + 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 = 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 { + 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 = 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/.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/.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() }