feat(governance): Phase 5 — vote_delegate + drep_register/deregister
Conway-era governance MCP tools, key-credentialed (script credentials deferred to Phase 6). aldabra-core/src/governance.rs (new ~500 LOC): - DRepTarget enum + parse_drep_target (handles bech32 drep1.../ drep_script1... + named 'abstain' / 'no_confidence') - build_signed_vote_delegation — Certificate::VoteDeleg(stake_cred, drep), reuses the dual-witness 2-pass-fee pattern from stake.rs. Optional register_first prepends StakeRegistration. - build_signed_drep_registration — Certificate::RegDRepCert with optional CIP-100/119 anchor + 500 ADA deposit - build_signed_drep_deregistration — Certificate::UnRegDRepCert with refund-aware change calc (deposit returns to wallet) - DREP_REGISTRATION_DEPOSIT_LOVELACE constant (500 ADA, mainnet) Made stake_key_as_payment_proxy pub(crate) so governance.rs can reuse the stake-key-as-witness trick. aldabra-mcp/src/tools.rs: - wallet_vote_delegate (drep + register_first) - wallet_drep_register (optional anchor_url + anchor_data_hash_hex) - wallet_drep_deregister (no args) 3 unit tests on parse_drep_target + DRepTarget→DRep round-trip. Phase 6 (vote_cast for DReps voting on Conway gov actions) blocked on extending Sulkta-Coop/pallas-txbuilder to thread voting_procedures through StagingTransaction (currently TODO at conway.rs:254). Same pattern as the aux_data + certificates patches already in the fork. Estimated ~300-500 LOC fork patch + ~400 LOC vote-cast builder. Surface to Cobb before starting.
This commit is contained in:
parent
d007817796
commit
4d3ef03978
4 changed files with 763 additions and 2 deletions
573
crates/aldabra-core/src/governance.rs
Normal file
573
crates/aldabra-core/src/governance.rs
Normal file
|
|
@ -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<DRepTarget, WalletError> {
|
||||
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<u8> = Vec::<u8>::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, WalletError> {
|
||||
pallas_addresses::Address::from_bech32(bech32)
|
||||
.map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, 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<Vec<u8>, WalletError> {
|
||||
let stake_pkh = stake_key.public_key_hash();
|
||||
let credential = StakeCredential::AddrKeyhash(stake_pkh);
|
||||
|
||||
let mut cert_bytes_list: Vec<Vec<u8>> = 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<Vec<u8>, 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<Anchor> = 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<Vec<u8>, 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<Vec<u8>>,
|
||||
deposit: u64,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, 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<InputUtxo> = available_utxos.to_vec();
|
||||
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
let mut acc: u64 = 0;
|
||||
let mut selected: Vec<InputUtxo> = 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<String, u64> = 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<StagingTransaction, WalletError> {
|
||||
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<Vec<u8>>,
|
||||
refund: u64,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, 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<InputUtxo> = available_utxos.to_vec();
|
||||
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
let mut acc: u64 = 0;
|
||||
let mut selected: Vec<InputUtxo> = 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<StagingTransaction, WalletError> {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue