feat(dao): proposal_cosign builder + dao_proposal_cosign_unsigned tool
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.
This commit is contained in:
parent
68e493dd2f
commit
39b56223f9
3 changed files with 897 additions and 1 deletions
|
|
@ -19,3 +19,4 @@
|
|||
|
||||
pub mod proposal_create;
|
||||
pub mod proposal_vote;
|
||||
pub mod proposal_cosign;
|
||||
|
|
|
|||
673
crates/aldabra-dao/src/builder/proposal_cosign.rs
Normal file
673
crates/aldabra-dao/src/builder/proposal_cosign.rs
Normal file
|
|
@ -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<u8>,
|
||||
/// Cosigner wallet's bech32 address (for change).
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs.
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// 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<Vec<Credential>> {
|
||||
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<Credential> = 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<UnsignedProposalCosign> {
|
||||
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<WalletUtxo> = 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<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn other_pkh_a() -> Vec<u8> { vec![0x10u8; 28] }
|
||||
fn other_pkh_b() -> Vec<u8> { 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> {
|
||||
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("<unset>"),
|
||||
)
|
||||
})?;
|
||||
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<String>,
|
||||
/// 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<i64, String> {
|
|||
.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<Vec<DaoWalletUtxo>, 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/<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_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/<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). 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