feat(dao-mcp): wire dao_proposal_vote_unsigned + slot↔ms helper
- DaoProposalVoteArgs (dao? + proposal_id + result_tag + fee_lovelace) - mainnet_slot_to_posix_ms: Shelley genesis constants (slot 4_492_800, posix 1_596_059_091_000) for converting tip+VALIDITY_RANGE_SLOTS into the Voted lock's posix_time field - pull_proposal_utxos helper: address_info → decode every UTxO's inline datum into ProposalDatum, return matching proposal_id by id match (`KoiosDaoReader::list_proposals` is still stubbed; this is a focused write-path read) - mainnet-only network gate (preprod/preview slot↔ms is Phase 5) - get_info instructions text mentions write tools - drop unused pallas_addresses::Address + pallas_txbuilder::ExUnits imports surfaced by clippy
This commit is contained in:
parent
a19439f640
commit
3b0e0dd9bf
2 changed files with 390 additions and 3 deletions
|
|
@ -59,10 +59,9 @@
|
|||
//! - **Vote retraction** — `RetractVotes` redeemer path is its own builder
|
||||
//! (Phase 4 follow-up).
|
||||
|
||||
use pallas_addresses::Address;
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, StagingTransaction};
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction};
|
||||
|
||||
use crate::agora::proposal::{
|
||||
ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ use aldabra_dao::builder::proposal_create::{
|
|||
build_unsigned_proposal_create, GovernorUtxoIn, ProposalCreateArgs, ReferenceUtxo, StakeUtxoIn,
|
||||
WalletUtxo as DaoWalletUtxo,
|
||||
};
|
||||
use aldabra_dao::builder::proposal_vote::{
|
||||
build_unsigned_proposal_vote, ProposalUtxoIn, ProposalVoteArgs,
|
||||
};
|
||||
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs};
|
||||
use aldabra_dao::discovery::{
|
||||
apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER,
|
||||
|
|
@ -1796,6 +1799,235 @@ impl WalletService {
|
|||
.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."
|
||||
)]
|
||||
async fn dao_proposal_vote_unsigned(
|
||||
&self,
|
||||
#[tool(aggr)] DaoProposalVoteArgs {
|
||||
dao,
|
||||
proposal_id,
|
||||
result_tag,
|
||||
fee_lovelace,
|
||||
}: DaoProposalVoteArgs,
|
||||
) -> Result<String, String> {
|
||||
let cfg = self
|
||||
.inner
|
||||
.dao_store
|
||||
.resolve(dao.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Network gate: slot↔ms conversion is mainnet-only for v1.
|
||||
if !matches!(cfg.network, DaoNetwork::Mainnet) {
|
||||
return Err(format!(
|
||||
"dao_proposal_vote_unsigned only supports mainnet for v1 \
|
||||
(current dao network: {:?}); preprod/preview slot↔ms conversion \
|
||||
needs the network's Shelley genesis constants — TODO Phase 5",
|
||||
cfg.network
|
||||
));
|
||||
}
|
||||
|
||||
let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||
"DaoConfig.proposal_addr missing — register or discover_scripts first".to_string()
|
||||
})?;
|
||||
|
||||
// Find this proposal among the UTxOs at proposal_addr. Use the
|
||||
// dedicated `pull_proposal_utxos` helper which decodes inline
|
||||
// datums (the regular `chain.get_utxos` doesn't surface inline
|
||||
// datum bytes).
|
||||
let mut found: Option<(
|
||||
String, // tx_hash
|
||||
u32, // output_index
|
||||
u64, // lovelace
|
||||
String, // proposal_st asset name hex
|
||||
aldabra_dao::agora::proposal::ProposalDatum,
|
||||
)> = None;
|
||||
let pulled = pull_proposal_utxos(&self.inner.koios_base, proposal_addr).await?;
|
||||
for p in pulled {
|
||||
if p.datum.proposal_id == proposal_id {
|
||||
found = Some((
|
||||
p.tx_hash,
|
||||
p.output_index,
|
||||
p.lovelace,
|
||||
p.proposal_st_asset_name_hex,
|
||||
p.datum,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (prop_tx, prop_idx, prop_lovelace, prop_st_name_hex, prop_datum) =
|
||||
found.ok_or_else(|| {
|
||||
format!("no proposal with proposal_id={} found at {}", proposal_id, proposal_addr)
|
||||
})?;
|
||||
|
||||
// Find the voter's stake.
|
||||
let voter_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 == &voter_pkh,
|
||||
_ => false,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"no stake at stakes_addr owned by this wallet's pkh {} — \
|
||||
wallet must hold a registered stake to vote",
|
||||
hex::encode(&voter_pkh)
|
||||
)
|
||||
})?;
|
||||
let (stake_tx, stake_idx) = parse_utxo_ref(&my_stake.utxo_ref)?;
|
||||
|
||||
// StakeST asset name from chain (matches H-6 fix in proposal_create).
|
||||
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())?;
|
||||
|
||||
// Chain tip slot + compute validity_upper_ms (mainnet only).
|
||||
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}"))?;
|
||||
let validity_upper_slot = tip_slot
|
||||
+ aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS;
|
||||
let validity_upper_ms = mainnet_slot_to_posix_ms(validity_upper_slot)?;
|
||||
|
||||
// Wallet utxos with H-5-style asset propagation.
|
||||
let wallet_utxos: Vec<DaoWalletUtxo> = {
|
||||
let raw = self
|
||||
.inner
|
||||
.chain
|
||||
.get_utxos(&self.inner.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",
|
||||
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,
|
||||
});
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
// ScriptRefs: stake + proposal validators.
|
||||
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_vote(ProposalVoteArgs {
|
||||
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: prop_lovelace,
|
||||
proposal_st_asset_name_hex: prop_st_name_hex,
|
||||
datum: prop_datum,
|
||||
},
|
||||
voter_pkh,
|
||||
result_tag,
|
||||
change_address: self.inner.address.clone(),
|
||||
wallet_utxos,
|
||||
tip_slot,
|
||||
validity_upper_ms,
|
||||
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,
|
||||
"result_tag": unsigned.result_tag,
|
||||
"vote_weight": unsigned.vote_weight,
|
||||
"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_my_stake",
|
||||
description = "Filter dao_stake_list to just the stake owned by THIS wallet (by matching the wallet's payment-credential hash against StakeDatum.owner). Returns the same shape as dao_stake_list but with at most one entry. If the wallet has no stake yet, returns an empty stakes array. Args: dao (optional — defaults to active)."
|
||||
|
|
@ -1930,6 +2162,50 @@ pub struct DaoProposalCreateArgs {
|
|||
pub starting_time_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DaoProposalVoteArgs {
|
||||
/// Named DAO. Falls through to active if omitted.
|
||||
#[serde(default)]
|
||||
pub dao: Option<String>,
|
||||
/// Proposal id (matches `ProposalDatum.proposal_id` on chain).
|
||||
pub proposal_id: i64,
|
||||
/// Result tag to vote for. For Sulkta InfoOnly: 0 = "yes", 1 = "no".
|
||||
/// Must already be a key in the proposal's votes map.
|
||||
pub result_tag: i64,
|
||||
/// Estimated total fee in lovelace. v1 caller-supplied; ~2.5 ADA is
|
||||
/// reasonable for a 2-script-spend vote tx.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion.
|
||||
///
|
||||
/// On Cardano mainnet, slot 4_492_800 corresponds to 2020-07-29 21:44:51 UTC
|
||||
/// (POSIX 1_596_059_091 seconds), and Shelley+ era slots are 1 second wide.
|
||||
/// Source: Cardano genesis files.
|
||||
const MAINNET_SHELLEY_SLOT_ZERO: u64 = 4_492_800;
|
||||
const MAINNET_SHELLEY_POSIX_MS_ZERO: i64 = 1_596_059_091_000;
|
||||
|
||||
/// Convert an absolute mainnet slot to POSIX milliseconds.
|
||||
///
|
||||
/// Caveat: only valid for slots ≥ `MAINNET_SHELLEY_SLOT_ZERO`. Returns
|
||||
/// `Err` if slot is in the Byron era (pre-4_492_800) since slot lengths
|
||||
/// differed there. We never need pre-Shelley slots for DAO operations.
|
||||
fn mainnet_slot_to_posix_ms(slot: u64) -> Result<i64, String> {
|
||||
if slot < MAINNET_SHELLEY_SLOT_ZERO {
|
||||
return Err(format!(
|
||||
"slot {slot} is pre-Shelley (< {MAINNET_SHELLEY_SLOT_ZERO}); \
|
||||
slot↔ms conversion only supported for Shelley+ era"
|
||||
));
|
||||
}
|
||||
let delta_slots = slot - MAINNET_SHELLEY_SLOT_ZERO;
|
||||
let delta_ms = (delta_slots as i64).checked_mul(1000).ok_or_else(|| {
|
||||
format!("slot delta {delta_slots} * 1000 overflows i64")
|
||||
})?;
|
||||
MAINNET_SHELLEY_POSIX_MS_ZERO
|
||||
.checked_add(delta_ms)
|
||||
.ok_or_else(|| "posix_ms add overflow".into())
|
||||
}
|
||||
|
||||
/// Parse a `txhash#index` UTxO ref into its components.
|
||||
fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> {
|
||||
let (h, i) = s
|
||||
|
|
@ -1939,6 +2215,118 @@ fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> {
|
|||
Ok((h.to_string(), idx))
|
||||
}
|
||||
|
||||
/// One proposal UTxO with decoded datum + ProposalST asset name.
|
||||
///
|
||||
/// Sized so [`dao_proposal_vote_unsigned`] can find a specific proposal_id
|
||||
/// without dragging the full Koios JSON shape through the call site.
|
||||
struct PulledProposal {
|
||||
tx_hash: String,
|
||||
output_index: u32,
|
||||
lovelace: u64,
|
||||
proposal_st_asset_name_hex: String,
|
||||
datum: aldabra_dao::agora::proposal::ProposalDatum,
|
||||
}
|
||||
|
||||
/// Pull every UTxO at the proposal address with a decoded ProposalDatum.
|
||||
///
|
||||
/// Used by `dao_proposal_vote_unsigned` to find a specific proposal by id.
|
||||
/// Phase 1 didn't wire this to `KoiosDaoReader::list_proposals` because the
|
||||
/// reader trait doesn't surface ProposalST asset info — we need both the
|
||||
/// ProposalST asset name (for datum-bearing assets) AND the bech32 utxo ref,
|
||||
/// so a focused helper here is cleaner than adding fields to the trait.
|
||||
async fn pull_proposal_utxos(
|
||||
koios_base: &str,
|
||||
proposal_addr: &str,
|
||||
) -> Result<Vec<PulledProposal>, String> {
|
||||
use aldabra_dao::agora::proposal::ProposalDatum;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddrInfo {
|
||||
utxo_set: Vec<Utxo>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Utxo {
|
||||
tx_hash: String,
|
||||
tx_index: u32,
|
||||
value: String,
|
||||
#[serde(default)]
|
||||
asset_list: Option<Vec<Asset>>,
|
||||
#[serde(default)]
|
||||
inline_datum: Option<InlineDatum>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Asset {
|
||||
policy_id: String,
|
||||
asset_name: Option<String>,
|
||||
quantity: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct InlineDatum {
|
||||
bytes: String,
|
||||
}
|
||||
|
||||
let url = format!("{}/address_info", koios_base.trim_end_matches('/'));
|
||||
let body = serde_json::json!({ "_addresses": [proposal_addr] });
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("reqwest build: {e}"))?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("address_info: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("address_info: HTTP {}", resp.status()));
|
||||
}
|
||||
let infos: Vec<AddrInfo> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("address_info parse: {e}"))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for info in infos {
|
||||
for u in info.utxo_set {
|
||||
let Some(d) = u.inline_datum else { continue };
|
||||
let datum_bytes = match hex::decode(&d.bytes) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let pd: pallas_primitives::PlutusData =
|
||||
match pallas_codec::minicbor::decode(&datum_bytes) {
|
||||
Ok(pd) => pd,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let datum = match ProposalDatum::from_plutus_data(&pd) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Find a non-zero qty asset whose policy looks like the
|
||||
// ProposalST policy. We don't have that here directly — caller
|
||||
// can match on it; we just expose the asset_name of the first
|
||||
// singleton asset (Sulkta convention).
|
||||
let proposal_st_asset_name_hex = u
|
||||
.asset_list
|
||||
.as_ref()
|
||||
.and_then(|al| al.first())
|
||||
.and_then(|a| a.asset_name.clone())
|
||||
.unwrap_or_default();
|
||||
let lovelace = u.value.parse().unwrap_or(0);
|
||||
out.push(PulledProposal {
|
||||
tx_hash: u.tx_hash,
|
||||
output_index: u.tx_index,
|
||||
lovelace,
|
||||
proposal_st_asset_name_hex,
|
||||
datum,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render a [`aldabra_dao::reader::StakeUtxo`] as a JSON object for tool output.
|
||||
///
|
||||
/// Formatted as a free function rather than `impl Serialize for StakeUtxo` to
|
||||
|
|
@ -1995,7 +2383,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). Phase 1 read-only; voting/proposing land in subsequent phases.".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_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