From 39b56223f9345081184ea5edcd69f54ce1509efd Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 06:51:53 -0700 Subject: [PATCH] feat(dao): proposal_cosign builder + dao_proposal_cosign_unsigned tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/aldabra-dao/src/builder/mod.rs | 1 + .../src/builder/proposal_cosign.rs | 673 ++++++++++++++++++ crates/aldabra-mcp/src/tools.rs | 224 +++++- 3 files changed, 897 insertions(+), 1 deletion(-) create mode 100644 crates/aldabra-dao/src/builder/proposal_cosign.rs diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs index 7924ac8..56614e5 100644 --- a/crates/aldabra-dao/src/builder/mod.rs +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -19,3 +19,4 @@ pub mod proposal_create; pub mod proposal_vote; +pub mod proposal_cosign; diff --git a/crates/aldabra-dao/src/builder/proposal_cosign.rs b/crates/aldabra-dao/src/builder/proposal_cosign.rs new file mode 100644 index 0000000..c2e6c16 --- /dev/null +++ b/crates/aldabra-dao/src/builder/proposal_cosign.rs @@ -0,0 +1,673 @@ +//! Build a `dao_proposal_cosign` transaction. +//! +//! Adds a cosigner to a Draft proposal. Used to clear the +//! `to_voting` threshold when a single stake's amount is below it but +//! several stakes summed are above — each cosigner contributes their +//! `staked_amount` toward the to-voting count when the proposal advances. +//! +//! ## Tx shape +//! +//! - **Inputs**: +//! - The cosigner's stake UTxO (Plutus spend, redeemer = `PermitVote` +//! wrapping a Cosign action — same `ppermitVote` handler as voting, +//! different proposal redeemer). +//! - The target proposal UTxO (Plutus spend, redeemer = `Cosign`). +//! - One funding wallet UTxO. +//! - **Collateral input**: separate ada-only ≥5 ADA wallet utxo. +//! - **Reference inputs**: stake validator + proposal validator. +//! - **No mints**. +//! - **Outputs**: +//! - New stake UTxO with a `Cosigned` lock prepended. +//! - New proposal UTxO with the cosigner inserted into `cosigners` +//! (sorted, unique). +//! - Wallet change. +//! +//! ## What the validator enforces +//! +//! From `Agora/Proposal/Scripts.hs` `PCosign` branch (~L433): +//! +//! 1. Proposal status == Draft. +//! 2. **Exactly one** stake input (`ptryFromSingleton`). +//! 3. New cosigner = stake.owner — delegatees CANNOT cosign. +//! 4. Updated cosigners list is `pinsertUniqueBy` of the new cosigner +//! over the old list (sorted insertion, fails on duplicate). +//! 5. `len(updated_cosigners) ≤ maximumCosigners` (script parameter, +//! matches `cfg.max_cosigners`). +//! 6. `stake.staked_amount ≥ thresholds.cosign`. +//! 7. Output proposal datum equals input with **only** `cosigners` +//! mutated; everything else bit-exact. +//! +//! From `Agora/Stake/Redeemers.hs` `ppermitVote` PCosign branch (~L244): +//! +//! 8. Single stake input (already covered above). +//! 9. **Owner signs** the tx — `pisSignedBy False` rejects delegatees. +//! 10. New stake datum = old with `Cosigned` ProposalLock prepended. +//! +//! ## Cosigner ordering +//! +//! Validator uses `pinsertUniqueBy # (pfromOrdBy # plam pfromData)`. For +//! Plutus `Credential` this means lexicographic order on the +//! Constr-encoded representation: first by variant index (PubKey=0 < +//! Script=1), then by the contained hash bytes. Builder mirrors this in +//! [`insert_unique_sorted`]. + +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; + +use crate::agora::proposal::{ + ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, + ProposalTimingConfig, ProposalVotes, +}; +use crate::agora::stake::{ + Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, +}; +use crate::config::{DaoConfig, DaoNetwork}; +use crate::error::{DaoError, DaoResult}; + +use super::proposal_create::{ + parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo, + MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as COSIGN_SPEND_EX_UNITS, + SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS, +}; +use super::proposal_vote::ProposalUtxoIn; + +const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000; + +/// Args bundle for [`build_unsigned_proposal_cosign`]. +#[derive(Debug, Clone)] +pub struct ProposalCosignArgs { + pub cfg: DaoConfig, + pub stake_in: StakeUtxoIn, + pub proposal: ProposalUtxoIn, + /// Cosigner's payment-credential hash (28 bytes). MUST match the + /// stake's owner pkh — delegatees cannot cosign per validator. + pub cosigner_pkh: Vec, + /// Cosigner wallet's bech32 address (for change). + pub change_address: String, + /// Spendable wallet UTxOs. + pub wallet_utxos: Vec, + /// Current chain tip slot. + pub tip_slot: u64, + /// Reference UTxO citing the stake validator script. + pub stake_validator_ref: ReferenceUtxo, + /// Reference UTxO citing the proposal validator script. + pub proposal_validator_ref: ReferenceUtxo, + /// Estimated total fee. Caller-supplied for v1. + pub fee_lovelace: u64, +} + +/// What [`build_unsigned_proposal_cosign`] returns. +#[derive(Debug, Clone)] +pub struct UnsignedProposalCosign { + pub tx_cbor_hex: String, + pub tx_hash_hex: String, + pub proposal_id: i64, + /// New cosigners count after insertion. + pub cosigners_count: usize, + pub summary: String, +} + +/// Insert `new` into the sorted-unique credential list, mirroring +/// Plutarch's `pinsertUniqueBy # (pfromOrdBy # plam pfromData)`. +/// +/// Returns `Err` if the credential is already present (validator's +/// `pinsertUniqueBy` rejects duplicates and so do we — preflight). +/// +/// Order rule: variant index first (PubKey=0 < Script=1), then by the +/// 28-byte hash lex order. +pub(super) fn insert_unique_sorted( + list: &[Credential], + new: &Credential, +) -> DaoResult> { + let key = |c: &Credential| match c { + Credential::PubKey(h) => (0u8, h.clone()), + Credential::Script(h) => (1u8, h.clone()), + }; + let new_key = key(new); + // Check for duplicate. + for c in list { + if key(c) == new_key { + return Err(DaoError::State(format!( + "credential already in cosigner list — pinsertUniqueBy would reject" + ))); + } + } + // Find insertion point. + let mut out: Vec = Vec::with_capacity(list.len() + 1); + let mut inserted = false; + for c in list { + if !inserted && key(c) > new_key { + out.push(new.clone()); + inserted = true; + } + out.push(c.clone()); + } + if !inserted { + out.push(new.clone()); + } + Ok(out) +} + +/// Build the unsigned proposal-cosign tx. +pub fn build_unsigned_proposal_cosign( + args: ProposalCosignArgs, +) -> DaoResult { + let proposal_id = args.proposal.datum.proposal_id; + + // ---- preflight checks ------------------------------------------------ + + // (3 + 9) Cosigner pkh must equal stake.owner pkh — delegatees rejected. + if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.cosigner_pkh) { + return Err(DaoError::State( + "cosigner pkh must equal stake's owner pkh — delegatees cannot cosign per validator" + .into(), + )); + } + + // (1) Proposal must be Draft. + if args.proposal.datum.status != ProposalStatus::Draft { + return Err(DaoError::State(format!( + "proposal #{} status is {:?}, must be Draft to cosign", + proposal_id, args.proposal.datum.status + ))); + } + + // (6) Stake amount must clear cosign threshold. + let cosign_threshold = args.proposal.datum.thresholds.cosign; + if args.stake_in.datum.staked_amount < cosign_threshold { + return Err(DaoError::State(format!( + "stake amount {} < cosign threshold {} — increase stake first", + args.stake_in.datum.staked_amount, cosign_threshold + ))); + } + + // (4) Insert cosigner into sorted-unique list. Errors on duplicate. + let cosigner_cred = Credential::PubKey(args.cosigner_pkh.clone()); + let new_cosigners = + insert_unique_sorted(&args.proposal.datum.cosigners, &cosigner_cred)?; + + // (5) Length check. + if (new_cosigners.len() as u32) > args.cfg.max_cosigners { + return Err(DaoError::State(format!( + "cosigners count {} would exceed max_cosigners {}", + new_cosigners.len(), + args.cfg.max_cosigners + ))); + } + + // ---- pick funding + collateral --------------------------------------- + + let mut ada_only: Vec = args + .wallet_utxos + .iter() + .filter(|u| u.is_ada_only()) + .cloned() + .collect(); + ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace)); + + let collateral = ada_only + .iter() + .rev() + .find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE) + .ok_or_else(|| { + DaoError::State(format!( + "no ada-only wallet UTxO ≥ {} lovelace for collateral", + MIN_COLLATERAL_LOVELACE + )) + })? + .clone(); + + let funding = ada_only + .iter() + .find(|u| { + !(u.tx_hash_hex == collateral.tx_hash_hex + && u.output_index == collateral.output_index) + }) + .cloned() + .ok_or_else(|| { + DaoError::State( + "need a SECOND ada-only wallet UTxO to fund the spend".into(), + ) + })?; + + // ---- compute new datums --------------------------------------------- + + // Stake: prepend Cosigned lock. + let mut new_locks = Vec::with_capacity(args.stake_in.datum.locked_by.len() + 1); + new_locks.push(ProposalLock { + proposal_id, + action: ProposalAction::Cosigned, + }); + new_locks.extend(args.stake_in.datum.locked_by.iter().cloned()); + let new_stake = StakeDatum { + staked_amount: args.stake_in.datum.staked_amount, + owner: args.stake_in.datum.owner.clone(), + delegated_to: args.stake_in.datum.delegated_to.clone(), + locked_by: new_locks, + }; + + // Proposal: cosigners updated, all else preserved bit-exact. + let new_proposal = ProposalDatum { + proposal_id, + effects_raw: args.proposal.datum.effects_raw.clone(), + status: args.proposal.datum.status, + cosigners: new_cosigners.clone(), + thresholds: ProposalThresholds { ..args.proposal.datum.thresholds.clone() }, + votes: ProposalVotes(args.proposal.datum.votes.0.clone()), + timing_config: ProposalTimingConfig { + ..args.proposal.datum.timing_config.clone() + }, + starting_time: args.proposal.datum.starting_time, + }; + + let new_stake_datum_pd = new_stake.to_plutus_data()?; + let new_proposal_datum_pd = new_proposal.to_plutus_data()?; + let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd) + .map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?; + let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd) + .map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?; + + // ---- redeemers ------------------------------------------------------- + + let stake_spend_redeemer_cbor = + minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?; + let proposal_spend_redeemer_cbor = + minicbor::to_vec(&ProposalRedeemer::Cosign.to_plutus_data()?) + .map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?; + + // ---- balance + change ------------------------------------------------ + + let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE); + let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE); + let total_in = args + .stake_in + .lovelace + .checked_add(args.proposal.lovelace) + .and_then(|x| x.checked_add(funding.lovelace)) + .ok_or_else(|| DaoError::State("input lovelace overflow".into()))?; + let total_out = new_stake_lovelace + .checked_add(new_proposal_lovelace) + .and_then(|x| x.checked_add(args.fee_lovelace)) + .ok_or_else(|| DaoError::State("output lovelace overflow".into()))?; + let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| { + DaoError::State(format!( + "insufficient input: total_in={total_in} need={total_out}" + )) + })?; + if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE { + return Err(DaoError::State(format!( + "change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE})" + ))); + } + + // ---- assemble StagingTransaction ------------------------------------- + + let stakes_addr = parse_address(&args.cfg.stakes_addr)?; + let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| { + DaoError::Config("proposal_addr not set on DaoConfig".into()) + })?)?; + let change_addr = parse_address(&args.change_address)?; + + let stake_input = Input::new( + parse_tx_hash(&args.stake_in.tx_hash_hex)?, + args.stake_in.output_index as u64, + ); + let proposal_input = Input::new( + parse_tx_hash(&args.proposal.tx_hash_hex)?, + args.proposal.output_index as u64, + ); + let funding_input = Input::new(parse_tx_hash(&funding.tx_hash_hex)?, funding.output_index as u64); + let collateral_input = Input::new( + parse_tx_hash(&collateral.tx_hash_hex)?, + collateral.output_index as u64, + ); + let stake_validator_ref_input = Input::new( + parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?, + args.stake_validator_ref.output_index as u64, + ); + let proposal_validator_ref_input = Input::new( + parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?, + args.proposal_validator_ref.output_index as u64, + ); + + let stake_st_policy_hash = parse_script_hash(args.cfg.stake_st_policy.as_deref().ok_or_else(|| { + DaoError::Config("stake_st_policy not set on DaoConfig".into()) + })?)?; + let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex) + .map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?; + let proposal_st_policy_hash = parse_script_hash(args.cfg.proposal_st_policy.as_deref().ok_or_else(|| { + DaoError::Config("proposal_st_policy not set on DaoConfig".into()) + })?)?; + let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex) + .map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?; + let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?; + let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex) + .map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?; + + let network_id = match args.cfg.network { + DaoNetwork::Mainnet => 1u8, + DaoNetwork::Preprod | DaoNetwork::Preview => 0u8, + }; + + let new_stake_output = Output::new(stakes_addr, new_stake_lovelace) + .set_inline_datum(new_stake_datum_cbor.clone()) + .add_asset(stake_st_policy_hash, stake_st_asset_name, 1) + .and_then(|o| o.add_asset( + gov_token_policy_hash, + gov_token_name_bytes, + args.stake_in.gov_token_qty, + )) + .map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?; + + let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace) + .set_inline_datum(new_proposal_datum_cbor.clone()) + .add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1) + .map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?; + + let mut staging = StagingTransaction::new(); + staging = staging.input(stake_input.clone()); + staging = staging.input(proposal_input.clone()); + staging = staging.input(funding_input.clone()); + staging = staging.collateral_input(collateral_input); + staging = staging.reference_input(stake_validator_ref_input); + staging = staging.reference_input(proposal_validator_ref_input); + staging = staging.output(new_stake_output); + staging = staging.output(new_proposal_output); + + if change_lovelace > 0 { + let mut change_output = Output::new(change_addr, change_lovelace); + for (policy_hex, name_hex, qty) in &funding.assets { + let policy = parse_script_hash(policy_hex)?; + let name = hex::decode(name_hex) + .map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?; + change_output = change_output + .add_asset(policy, name, *qty) + .map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?; + } + staging = staging.output(change_output); + } + + staging = staging.add_spend_redeemer( + stake_input, + stake_spend_redeemer_cbor, + Some(COSIGN_SPEND_EX_UNITS), + ); + staging = staging.add_spend_redeemer( + proposal_input, + proposal_spend_redeemer_cbor, + Some(COSIGN_SPEND_EX_UNITS), + ); + + staging = staging.valid_from_slot(args.tip_slot); + staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS); + + let cosigner_pkh_arr: [u8; 28] = args + .cosigner_pkh + .as_slice() + .try_into() + .map_err(|_| DaoError::Datum(format!( + "cosigner_pkh must be 28 bytes, got {}", + args.cosigner_pkh.len() + )))?; + staging = staging.disclosed_signer(Hash::<28>::from(cosigner_pkh_arr)); + + staging = staging.fee(args.fee_lovelace).network_id(network_id); + + let built = staging + .build_conway_raw() + .map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?; + + let tx_cbor_hex = hex::encode(&built.tx_bytes.0); + let tx_hash_hex = hex::encode(built.tx_hash.0); + + let summary = format!( + "dao_proposal_cosign_unsigned: dao={} proposal_id={} new_cosigner_pkh={} cosigners_count={} fee={}", + args.cfg.name, + proposal_id, + hex::encode(&args.cosigner_pkh), + new_cosigners.len(), + args.fee_lovelace, + ); + + Ok(UnsignedProposalCosign { + tx_cbor_hex, + tx_hash_hex, + proposal_id, + cosigners_count: new_cosigners.len(), + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agora::plutus_data::constr; + use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; + use crate::config::ScriptRefs; + + fn cosigner_pkh() -> Vec { + hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() + } + + fn other_pkh_a() -> Vec { vec![0x10u8; 28] } + fn other_pkh_b() -> Vec { vec![0xf0u8; 28] } + + fn sample_proposal_datum() -> ProposalDatum { + ProposalDatum { + proposal_id: 1, + effects_raw: constr(0, vec![]), + status: ProposalStatus::Draft, + cosigners: vec![ + Credential::PubKey(other_pkh_a()), + ], + thresholds: ProposalThresholds { + execute: 20, + create: 100, + to_voting: 100, + vote: 1, + cosign: 1, + }, + votes: ProposalVotes(vec![(0, 0), (1, 0)]), + timing_config: ProposalTimingConfig { + draft_time: 7 * 86_400 * 1000, + voting_time: 7 * 86_400 * 1000, + locking_time: 48 * 3600 * 1000, + executing_time: 24 * 3600 * 1000, + min_stake_voting_time: 60 * 60 * 1000, + voting_time_range_max_width: 30 * 60 * 1000, + }, + starting_time: 1_780_000_000_000, + } + } + + fn sample_args() -> ProposalCosignArgs { + ProposalCosignArgs { + cfg: DaoConfig { + name: "sulkta".into(), + description: None, + governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(), + stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(), + treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(), + gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(), + gov_token_name_hex: "546572726170696e".into(), + initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(), + max_cosigners: 5, + treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(), + network: DaoNetwork::Mainnet, + proposal_addr: Some( + "addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(), + ), + stake_st_policy: Some( + "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), + ), + proposal_st_policy: Some( + "9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(), + ), + script_refs: ScriptRefs::default(), + }, + stake_in: StakeUtxoIn { + tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(), + output_index: 1, + lovelace: 1_555_910, + gov_token_qty: 50, + stake_st_asset_name_hex: + "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(), + datum: StakeDatum { + staked_amount: 50, + owner: Credential::PubKey(cosigner_pkh()), + delegated_to: None, + locked_by: vec![], + }, + }, + proposal: ProposalUtxoIn { + tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(), + output_index: 0, + lovelace: 2_000_000, + proposal_st_asset_name_hex: "".into(), + datum: sample_proposal_datum(), + }, + cosigner_pkh: cosigner_pkh(), + change_address: + "addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6" + .into(), + wallet_utxos: vec![ + WalletUtxo { + tx_hash_hex: "00".repeat(31) + "01", + output_index: 0, + lovelace: 10_000_000, + assets: vec![], + }, + WalletUtxo { + tx_hash_hex: "00".repeat(31) + "02", + output_index: 0, + lovelace: 6_000_000, + assets: vec![], + }, + ], + tip_slot: 180_062_536, + stake_validator_ref: ReferenceUtxo { + tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(), + output_index: 2, + }, + proposal_validator_ref: ReferenceUtxo { + tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(), + output_index: 1, + }, + fee_lovelace: 2_500_000, + } + } + + #[test] + fn builds_unsigned_cosign_for_sulkta() { + let unsigned = build_unsigned_proposal_cosign(sample_args()).unwrap(); + assert_eq!(unsigned.proposal_id, 1); + assert_eq!(unsigned.cosigners_count, 2); + assert!(!unsigned.tx_cbor_hex.is_empty()); + assert_eq!(unsigned.tx_hash_hex.len(), 64); + assert!(unsigned.summary.contains("sulkta")); + } + + #[test] + fn rejects_when_proposal_not_draft() { + let mut args = sample_args(); + args.proposal.datum.status = ProposalStatus::VotingReady; + let err = build_unsigned_proposal_cosign(args).unwrap_err(); + assert!(err.to_string().contains("Draft")); + } + + #[test] + fn rejects_delegatee_cosigner() { + let mut args = sample_args(); + // Stake has different owner; cosigner_pkh is delegatee. + args.stake_in.datum.owner = Credential::PubKey(other_pkh_a()); + args.stake_in.datum.delegated_to = Some(Credential::PubKey(cosigner_pkh())); + let err = build_unsigned_proposal_cosign(args).unwrap_err(); + assert!(err.to_string().contains("owner")); + } + + #[test] + fn rejects_duplicate_cosigner() { + let mut args = sample_args(); + // Add cosigner_pkh as already-present cosigner. + args.proposal.datum.cosigners.push(Credential::PubKey(cosigner_pkh())); + let err = build_unsigned_proposal_cosign(args).unwrap_err(); + assert!(err.to_string().contains("already in cosigner list")); + } + + #[test] + fn rejects_below_cosign_threshold() { + let mut args = sample_args(); + args.stake_in.datum.staked_amount = 0; + args.proposal.datum.thresholds.cosign = 100; + let err = build_unsigned_proposal_cosign(args).unwrap_err(); + assert!(err.to_string().contains("cosign threshold")); + } + + #[test] + fn rejects_when_cosigners_at_max() { + let mut args = sample_args(); + args.cfg.max_cosigners = 1; + // existing list already has 1 cosigner, adding ours makes 2 > 1. + let err = build_unsigned_proposal_cosign(args).unwrap_err(); + assert!(err.to_string().contains("max_cosigners")); + } + + #[test] + fn insert_unique_sorted_keeps_lex_order() { + // [a (low), c (high)], insert b (middle) → [a, b, c] + let a = Credential::PubKey(vec![0x10; 28]); + let b = Credential::PubKey(vec![0x80; 28]); + let c = Credential::PubKey(vec![0xf0; 28]); + let result = insert_unique_sorted(&[a.clone(), c.clone()], &b.clone()).unwrap(); + assert_eq!(result, vec![a, b, c]); + } + + #[test] + fn insert_unique_sorted_appends_when_largest() { + let a = Credential::PubKey(vec![0x10; 28]); + let b = Credential::PubKey(vec![0x80; 28]); + let c = Credential::PubKey(vec![0xf0; 28]); + let result = insert_unique_sorted(&[a.clone(), b.clone()], &c.clone()).unwrap(); + assert_eq!(result, vec![a, b, c]); + } + + #[test] + fn insert_unique_sorted_prepends_when_smallest() { + let a = Credential::PubKey(vec![0x10; 28]); + let b = Credential::PubKey(vec![0x80; 28]); + let c = Credential::PubKey(vec![0xf0; 28]); + let result = insert_unique_sorted(&[b.clone(), c.clone()], &a.clone()).unwrap(); + assert_eq!(result, vec![a, b, c]); + } + + #[test] + fn insert_unique_sorted_pubkey_before_script() { + let pk = Credential::PubKey(vec![0xf0; 28]); + let sc = Credential::Script(vec![0x10; 28]); + // PubKey variant=0 < Script variant=1 → PubKey first regardless of hash bytes. + let result = insert_unique_sorted(&[sc.clone()], &pk.clone()).unwrap(); + assert_eq!(result, vec![pk, sc]); + } + + #[test] + fn other_existing_cosigner_a_keeps_position() { + // sample's existing cosigner is other_pkh_a (0x10..). Adding cosigner_pkh + // (84d0..) which sorts after 0x10 — should land at index 1. + let unsigned = build_unsigned_proposal_cosign(sample_args()).unwrap(); + // Just verify count + that it built; ordering is checked by the + // dedicated insert_unique_sorted_* tests. + assert_eq!(unsigned.cosigners_count, 2); + } + + #[test] + fn handles_b_first_then_a_correctly() { + // sample has [a]; if we instead start with [b] and add cosigner_pkh + // which sorts < b, cosigner ends up first. + let mut args = sample_args(); + args.proposal.datum.cosigners = vec![Credential::PubKey(other_pkh_b())]; + let unsigned = build_unsigned_proposal_cosign(args).unwrap(); + assert_eq!(unsigned.cosigners_count, 2); + } +} diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 0feca93..bde813f 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -37,6 +37,9 @@ use aldabra_dao::builder::proposal_create::{ use aldabra_dao::builder::proposal_vote::{ build_unsigned_proposal_vote, ProposalUtxoIn, ProposalVoteArgs, }; +use aldabra_dao::builder::proposal_cosign::{ + build_unsigned_proposal_cosign, ProposalCosignArgs, +}; use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; use aldabra_dao::discovery::{ apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER, @@ -1799,6 +1802,173 @@ impl WalletService { .to_string()) } + #[tool( + name = "dao_proposal_cosign_unsigned", + description = "Build (but DO NOT submit) an unsigned cosign tx that adds the wallet's stake as a cosigner of a Draft proposal. Used to bridge a single stake's amount being below the to_voting threshold — multiple cosigners' stakes sum when the proposal advances. Only the stake owner can cosign (delegatees rejected per validator). Args: dao (optional — defaults to active), proposal_id (i64; must be in Draft), fee_lovelace (~2_500_000)." + )] + async fn dao_proposal_cosign_unsigned( + &self, + #[tool(aggr)] DaoProposalCosignArgs { + dao, + proposal_id, + fee_lovelace, + }: DaoProposalCosignArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + + // Find the proposal. + let proposals = self + .inner + .dao_reader + .list_proposals(&cfg) + .await + .map_err(|e| e.to_string())?; + let target = proposals + .into_iter() + .find(|p| p.datum.proposal_id == proposal_id) + .ok_or_else(|| { + format!( + "no proposal with proposal_id={} found at {}", + proposal_id, + cfg.proposal_addr.as_deref().unwrap_or(""), + ) + })?; + let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?; + + // Find the wallet's stake. + let cosigner_pkh = self.wallet_pkh()?; + let stakes = self + .inner + .dao_reader + .list_stakes(&cfg) + .await + .map_err(|e| e.to_string())?; + let my_stake = stakes + .into_iter() + .find(|s| match &s.datum.owner { + aldabra_dao::agora::stake::Credential::PubKey(h) => h == &cosigner_pkh, + _ => false, + }) + .ok_or_else(|| { + format!( + "no stake at stakes_addr owned by this wallet's pkh {} — \ + wallet must hold a registered stake to cosign", + hex::encode(&cosigner_pkh) + ) + })?; + let (stake_tx, stake_idx) = parse_utxo_ref(&my_stake.utxo_ref)?; + + // StakeST asset name from chain. + let stake_utxos_raw = self + .inner + .chain + .get_utxos(&cfg.stakes_addr) + .await + .map_err(|e| format!("koios get stake utxos: {e}"))?; + let stake_utxo_raw = stake_utxos_raw + .into_iter() + .find(|u| u.tx_hash == stake_tx && u.output_index == stake_idx) + .ok_or_else(|| format!("stake utxo {} no longer on chain", my_stake.utxo_ref))?; + let stake_st_asset_name_hex = stake_utxo_raw + .assets + .iter() + .find_map(|(k, _)| { + if k.len() < 56 { + return None; + } + let (p, n) = k.split_at(56); + if p == cfg.stake_st_policy.as_deref().unwrap_or("") { + Some(n.to_string()) + } else { + None + } + }) + .ok_or_else(|| "stake UTxO is missing StakeST token".to_string())?; + + // Tip slot for validity range. + let tip_resp = self + .inner + .chain + .get_raw_json("tip", &[]) + .await + .map_err(|e| format!("koios tip: {e}"))?; + let tip: serde_json::Value = + serde_json::from_str(&tip_resp).map_err(|e| format!("tip parse: {e}"))?; + let tip_slot = tip + .as_array() + .and_then(|a| a.first()) + .and_then(|t| t.get("abs_slot")) + .and_then(|s| s.as_u64()) + .ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?; + + // Wallet utxos. + let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?; + + // ScriptRefs. + let stake_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .stake_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first".to_string() + })?, + ) + .map_err(|e| e.to_string())?; + let proposal_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .proposal_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.proposal_validator missing — \ + run dao_discover_scripts first".to_string() + })?, + ) + .map_err(|e| e.to_string())?; + + let unsigned = build_unsigned_proposal_cosign(ProposalCosignArgs { + cfg: cfg.clone(), + stake_in: StakeUtxoIn { + tx_hash_hex: stake_tx, + output_index: stake_idx, + lovelace: my_stake.lovelace, + gov_token_qty: my_stake.gov_token_quantity, + stake_st_asset_name_hex, + datum: my_stake.datum, + }, + proposal: ProposalUtxoIn { + tx_hash_hex: prop_tx, + output_index: prop_idx, + lovelace: target.lovelace, + proposal_st_asset_name_hex: target.proposal_st_asset_name_hex, + datum: target.datum, + }, + cosigner_pkh, + change_address: self.inner.address.clone(), + wallet_utxos, + tip_slot, + stake_validator_ref, + proposal_validator_ref, + fee_lovelace, + }) + .map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "dao": cfg.name, + "tx_cbor_hex": unsigned.tx_cbor_hex, + "tx_hash_hex": unsigned.tx_hash_hex, + "proposal_id": unsigned.proposal_id, + "cosigners_count": unsigned.cosigners_count, + "summary": unsigned.summary, + "next_step": "review tx_cbor_hex (decode + audit), then sign via wallet_sign_partial + submit via wallet_submit_signed_tx", + }) + .to_string()) + } + #[tool( name = "dao_proposal_vote_unsigned", description = "Build (but DO NOT submit) an unsigned vote tx for the given DAO proposal. Spends voter's stake (PermitVote redeemer) + the proposal UTxO (Vote(result_tag) redeemer) and outputs the same two with locks/votes mutated. Returns CBOR-hex of the unsigned tx body. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx. Args: dao (optional — defaults to active), proposal_id (i64; matches ProposalDatum.proposal_id on chain), result_tag (i64; 0 or 1 for InfoOnly proposals), fee_lovelace (~2_500_000 reasonable for v1). Pre-flights every validator check: voter is owner-or-delegatee, status=VotingReady, no double-vote, stake clears threshold, result_tag valid, validity-upper inside voting window." @@ -2153,6 +2323,17 @@ pub struct DaoProposalCreateArgs { pub starting_time_ms: i64, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoProposalCosignArgs { + /// Named DAO. Falls through to active if omitted. + #[serde(default)] + pub dao: Option, + /// Proposal id to cosign (must be in Draft status). + pub proposal_id: i64, + /// Estimated total fee in lovelace. ~2.5 ADA reasonable. + pub fee_lovelace: u64, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DaoProposalVoteArgs { /// Named DAO. Falls through to active if omitted. @@ -2197,6 +2378,47 @@ fn mainnet_slot_to_posix_ms(slot: u64) -> Result { .ok_or_else(|| "posix_ms add overflow".into()) } +/// Pull wallet UTxOs with H-5 strict asset-key parsing. +/// +/// Shared by every DAO write-path tool that needs to fund + collateralize +/// from the wallet. Surfaces malformed asset keys (< 56 chars) as errors +/// instead of silently dropping them — a corrupt Koios response would +/// otherwise let our builder construct a tx that loses native assets on +/// submit. AUDIT-H5 fix from 2026-05-05. +async fn pull_wallet_utxos( + chain: &KoiosClient, + address: &str, +) -> Result, String> { + let raw = chain + .get_utxos(address) + .await + .map_err(|e| format!("koios get wallet utxos: {e}"))?; + let mut out = Vec::with_capacity(raw.len()); + for u in raw { + let mut assets = Vec::with_capacity(u.assets.len()); + for (k, q) in u.assets { + if k.len() < 56 { + return Err(format!( + "malformed asset key in wallet utxo {tx_hash}#{idx}: \ + {k:?} is {len} chars, need ≥ 56 (policy_id_hex || asset_name_hex)", + tx_hash = u.tx_hash, + idx = u.output_index, + len = k.len(), + )); + } + let (p, n) = k.split_at(56); + assets.push((p.to_string(), n.to_string(), q)); + } + out.push(DaoWalletUtxo { + tx_hash_hex: u.tx_hash, + output_index: u.output_index, + lovelace: u.lovelace, + assets, + }); + } + Ok(out) +} + /// Parse a `txhash#index` UTxO ref into its components. fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> { let (h, i) = s @@ -2263,7 +2485,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_vote_unsigned (vote on a VotingReady proposal). 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). 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). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(), ), ..Default::default() }