aldabra/crates/aldabra-mcp/src/tools.rs
Sulkta 77d4b2d0b5 feat(governance): Phase 5 — vote_delegate + drep_register/deregister
Conway-era governance MCP tools, key-credentialed (script credentials
deferred to Phase 6).

aldabra-core/src/governance.rs (new ~500 LOC):
- DRepTarget enum + parse_drep_target (handles bech32 drep1.../
  drep_script1... + named 'abstain' / 'no_confidence')
- build_signed_vote_delegation — Certificate::VoteDeleg(stake_cred,
  drep), reuses the dual-witness 2-pass-fee pattern from stake.rs.
  Optional register_first prepends StakeRegistration.
- build_signed_drep_registration — Certificate::RegDRepCert with
  optional CIP-100/119 anchor + 500 ADA deposit
- build_signed_drep_deregistration — Certificate::UnRegDRepCert with
  refund-aware change calc (deposit returns to wallet)
- DREP_REGISTRATION_DEPOSIT_LOVELACE constant (500 ADA, mainnet)

Made stake_key_as_payment_proxy pub(crate) so governance.rs can reuse
the stake-key-as-witness trick.

aldabra-mcp/src/tools.rs:
- wallet_vote_delegate (drep + register_first)
- wallet_drep_register (optional anchor_url + anchor_data_hash_hex)
- wallet_drep_deregister (no args)

3 unit tests on parse_drep_target + DRepTarget→DRep round-trip.

Phase 6 (vote_cast for DReps voting on Conway gov actions) blocked
on extending Sulkta-Coop/pallas-txbuilder to thread voting_procedures
through StagingTransaction (currently TODO at conway.rs:254). Same
pattern as the aux_data + certificates patches already in the fork.
Estimated ~300-500 LOC fork patch + ~400 LOC vote-cast builder. Surface
to Sulkta before starting.
2026-05-06 07:08:08 -07:00

3020 lines
117 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! MCP tool handlers.
//!
//! Each `#[tool]` becomes a discoverable MCP tool. Tool names use
//! `snake_case` only (no dots) — Claude Code's MCP client validates
//! tool names against `[a-zA-Z0-9_-]{1,64}` and silently drops names
//! with dots. This was an integration-time discovery 2026-05-04 after
//! the first session restart found zero aldabra tools advertised
//! despite the daemon running.
//!
//! ## Phase 1 — read path
//!
//! - `wallet_address` — bech32 base address
//! - `wallet_network` — mainnet | preview | preprod
//! - `wallet_balance` — JSON `{lovelace, assets}`
//! - `wallet_utxos` — JSON list of UTXOs
//!
//! ## Phase 2 — send path
//!
//! - `wallet_send` — build + sign + submit ADA payment, with hard
//! cap guard (`max_send_lovelace`)
//! - `wallet_tx_status` — poll a submitted tx hash
//!
//! Returns:
//! - `String` results pass through `IntoContents` directly.
//! - `Result<String, String>` lets us surface chain / build errors
//! as MCP tool-call errors instead of crashing the daemon.
use std::path::PathBuf;
use std::sync::Arc;
use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_dao::agora::stake::Credential as DaoCredential;
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::builder::proposal_cosign::{
build_unsigned_proposal_cosign, ProposalCosignArgs,
};
use aldabra_dao::builder::proposal_advance::{
build_unsigned_proposal_advance, AdvanceTransition, CosignerStakeRef, ProposalAdvanceArgs,
};
use aldabra_dao::builder::stake_destroy::{build_unsigned_stake_destroy, StakeDestroyArgs};
use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs};
use aldabra_dao::discovery::{
apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER,
};
use aldabra_dao::reader::{DaoReader, KoiosDaoReader};
use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD;
use aldabra_core::{
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation,
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, summarize_tx, AssetSpec,
InputUtxo, Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec,
ProtocolParams, StakeKey, DEFAULT_EX_UNITS,
};
use rmcp::{
model::{ServerCapabilities, ServerInfo},
schemars, tool, ServerHandler,
};
use serde::Deserialize;
/// MCP-facing asset spec — separate from `aldabra_core::AssetSpec`
/// so the JsonSchema derive doesn't bleed schemars into the
/// security-boundary crate.
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone)]
pub struct McpAssetSpec {
/// 56-char hex (28 bytes) Cardano policy ID.
pub policy_id_hex: String,
/// Hex-encoded asset name, 0-64 hex chars (0-32 bytes).
pub asset_name_hex: String,
pub quantity: u64,
}
impl From<McpAssetSpec> for AssetSpec {
fn from(m: McpAssetSpec) -> Self {
Self {
policy_id_hex: m.policy_id_hex,
asset_name_hex: m.asset_name_hex,
quantity: m.quantity,
}
}
}
#[derive(Clone)]
pub struct WalletService {
inner: Arc<WalletInner>,
}
struct WalletInner {
network: Network,
address: String,
chain: KoiosClient,
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
/// Per-DAO config store rooted at `<data_dir>/daos/`. See
/// `aldabra_dao::config::DaoStore` — load/save/active selector.
dao_store: DaoStore,
/// Reader-only Koios client for DAO-shape queries. Reuses the
/// koios_base; separate from `chain` so the trait surface stays clean.
dao_reader: KoiosDaoReader,
/// Cached Koios base url so `dao_discover_scripts` can spin up a
/// `KoiosDiscoveryClient` on demand without a re-construction call.
koios_base: String,
}
impl WalletService {
pub fn new(
network: Network,
address: String,
koios_base: String,
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
data_dir: PathBuf,
) -> Self {
Self {
inner: Arc::new(WalletInner {
network,
address,
chain: KoiosClient::new(koios_base.clone()),
payment_key,
stake_key,
max_send_lovelace,
dao_store: DaoStore::new(&data_dir),
dao_reader: KoiosDaoReader::new(koios_base.clone()),
koios_base,
}),
}
}
/// Extract the wallet's payment-credential hash from the bech32
/// address. Used by `dao_my_stake` to match against
/// `StakeDatum.owner`. Returns the 28-byte pkh.
fn wallet_pkh(&self) -> Result<Vec<u8>, String> {
use pallas_addresses::{Address, ShelleyPaymentPart};
let addr = Address::from_bech32(&self.inner.address)
.map_err(|e| format!("address parse: {e}"))?;
match addr {
Address::Shelley(s) => match s.payment() {
ShelleyPaymentPart::Key(h) => Ok(h.as_ref().to_vec()),
ShelleyPaymentPart::Script(_) => {
Err("wallet address is script-credentialed; can't be a stake owner".into())
}
},
_ => Err("wallet address is not Shelley-era; unsupported".into()),
}
}
/// Reject if `lovelace` exceeds the wallet's hard cap unless
/// `force=true`. Used by every tool that moves lovelace to a
/// non-wallet destination — wallet_send, wallet_mint,
/// wallet_mint_cip68_nft, wallet_script_spend.
/// (HIGH-1 audit fix: previously only wallet_send had this guard.)
fn enforce_value_cap(&self, lovelace: u64, force: bool) -> Result<(), String> {
if lovelace > self.inner.max_send_lovelace && !force {
return Err(format!(
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
self.inner.max_send_lovelace
));
}
Ok(())
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SendArgs {
/// Recipient bech32 address.
pub to_address: String,
/// Amount to send in lovelace (1 ADA = 1_000_000 lovelace).
pub lovelace: u64,
/// Optional native assets to include in the payment output.
/// Each entry needs the policy_id (56 hex chars) + asset_name
/// (hex of raw bytes, 0-64 chars) + quantity.
#[serde(default)]
pub assets: Vec<McpAssetSpec>,
/// Optional inline-datum CBOR (hex). When set, the recipient
/// output carries the bytes as an inline datum — the right
/// shape for locking funds at a script address with a datum
/// the validator can read. Required for any spend-back path
/// against Babbage/Conway-era validators (script utxos without
/// a datum are un-spendable). Omit for normal sends.
#[serde(default)]
pub datum_inline_cbor_hex: Option<String>,
/// Bypass the configured `max_send_lovelace` hard cap. Only
/// pass `true` for an intentional, user-confirmed large send.
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TxStatusArgs {
/// Hex-encoded transaction hash returned by `wallet_send`.
pub tx_hash: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainTxInfoArgs {
/// Hex-encoded transaction hash to look up.
pub tx_hash: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainAddressArgs {
/// Bech32 address (any network).
pub address: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainPoolListArgs {
/// Optional exact-match ticker filter (e.g. "AHL", "BLOOM", "1PCT").
#[serde(default)]
pub ticker: Option<String>,
/// Optional exact-match bech32 pool id filter.
#[serde(default)]
pub pool_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainPoolInfoArgs {
/// One or more bech32 pool ids to fetch detail for.
pub pool_ids: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainEpochArgs {
/// Specific epoch number; omit for the latest active epoch.
#[serde(default)]
pub epoch_no: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainAssetArgs {
/// 56-hex-char policy id.
pub policy_id_hex: String,
/// Hex of raw asset name bytes (064 chars). Empty string for
/// policy-only / no-asset-name native assets.
pub asset_name_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ChainAccountArgs {
/// Bech32 stake address (`stake1...` mainnet or `stake_test1...`).
pub stake_address: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct UnsignedSendArgs {
/// Recipient bech32 address.
pub to_address: String,
/// Amount to send in lovelace.
pub lovelace: u64,
/// Optional native assets to include in the payment output.
#[serde(default)]
pub assets: Vec<McpAssetSpec>,
/// Optional inline-datum CBOR (hex). See [`SendArgs::datum_inline_cbor_hex`].
#[serde(default)]
pub datum_inline_cbor_hex: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SubmitSignedArgs {
/// Hex-encoded signed transaction CBOR — produced by an external
/// cold-signer that consumed the unsigned CBOR returned by
/// `wallet_send_unsigned`.
pub signed_cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PolicyCreateArgs {
/// Optional slot after which the policy becomes invalid. Use
/// to lock supply (Cardano idiom: mint then expire). Omit for
/// an open-ended policy that allows mint/burn forever.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MintUnsignedArgs {
pub dest_address: String,
pub dest_lovelace: u64,
pub asset_name_hex: String,
pub quantity: i64,
/// PolicySpec as a JSON object with `type`: `single_sig` |
/// `single_sig_timelock` | `nofk`. If omitted, defaults to a
/// single-sig policy bound to this wallet's payment key (same as
/// `wallet_mint`).
#[serde(default)]
pub policy: Option<serde_json::Value>,
/// Optional CIP-25 v2 metadata.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// Hex of the pkh to disclose as a required signer in the tx
/// body. Defaults to this wallet's payment key hash. For
/// multi-sig flows where you want a different signer hint, pass
/// it explicitly. For native-script-only mints this field is
/// optional metadata for downstream signers.
#[serde(default)]
pub disclosed_signer_pkh_hex: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ScriptSpendArgs {
/// Hex-encoded tx hash of the locked UTXO.
pub locked_tx_hash: String,
/// Output index of the locked UTXO at that tx.
pub locked_output_index: u32,
/// Lovelace at the locked UTXO.
pub locked_lovelace: u64,
/// Plutus version: "v1", "v2", or "v3".
pub plutus_version: String,
/// Hex-encoded Plutus script CBOR.
pub script_cbor_hex: String,
/// Hex-encoded redeemer (PlutusData CBOR).
pub redeemer_cbor_hex: String,
/// Optional witness datum hex. Omit if datum is inline on the
/// locked UTXO.
#[serde(default)]
pub witness_datum_hex: Option<String>,
/// Where the unlocked funds go.
pub payout_address: String,
/// Lovelace to send to payout address.
pub payout_lovelace: u64,
/// Optional ExUnits override `{"mem": ..., "steps": ...}`. Omit
/// for the conservative default budget (works for trivial
/// validators; tune for real ones).
#[serde(default)]
pub ex_units: Option<ExUnitsArg>,
/// Bypass the hard cap on `payout_lovelace`. Required if a
/// Plutus spend unlocks a large UTXO and routes it elsewhere.
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExUnitsArg {
pub mem: u64,
pub steps: u64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct StakeDelegateArgs {
/// Stake pool bech32 ID (`pool1...`).
pub pool_id: String,
/// If true, prepends a stake-registration certificate (one-time
/// 2 ADA deposit, refunded on deregistration). Set to false if
/// this wallet's stake key is already registered (re-delegation).
/// Defaults to true (most users delegating for the first time).
#[serde(default = "default_register_first")]
pub register_first: bool,
}
fn default_register_first() -> bool {
true
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct VoteDelegateArgs {
/// DRep target. One of:
/// - bech32 DRep ID (e.g. "drep1abc..." or "drep_script1abc...")
/// - "abstain" — predefined always-abstain DRep
/// - "no_confidence" — predefined no-confidence DRep
pub drep: String,
/// If true, prepends a stake-registration certificate (one-time
/// 2 ADA deposit). Set false if already registered.
#[serde(default = "default_register_first")]
pub register_first: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DrepRegisterArgs {
/// Optional CIP-100/119 anchor URL (off-chain DRep metadata).
#[serde(default)]
pub anchor_url: Option<String>,
/// 64-char hex blake2b-256 of the off-chain anchor content. Both
/// anchor_url and anchor_data_hash_hex must be set or both omitted.
#[serde(default)]
pub anchor_data_hash_hex: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DrepDeregisterArgs {
// No args — uses the wallet's stake credential.
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TxSummaryArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, partially-signed,
/// or fully signed. Decoded read-only and turned into a
/// human-reviewable JSON summary. **Always run this before
/// `wallet_sign_partial` or `wallet_submit_signed_tx` on a
/// CBOR you didn't build yourself.**
pub cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SignPartialArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
/// partially signed by another party. The wallet's payment key
/// gets appended as an additional VKeyWitness.
pub cbor_hex: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct Cip68NftArgs {
/// Recipient address — receives the user NFT (label 222).
pub user_address: String,
/// ADA to attach to the user NFT output (≥ 1.5 ADA min).
/// Defaults to 1_500_000 lovelace if omitted.
#[serde(default = "default_token_lovelace")]
pub user_lovelace: u64,
/// Hex-encoded asset name body (0-28 bytes; the 4-byte CIP-68
/// prefix gets prepended automatically). Same body is shared
/// between the ref NFT (100) and the user NFT (222).
pub name_body_hex: String,
/// CIP-68 metadata JSON object (`name`, `image`, `description`,
/// `mediaType`, `files`, etc.). Encoded as Plutus Data and
/// attached as the inline datum on the ref-NFT output.
pub metadata: serde_json::Value,
/// Optional address where the reference NFT lives. Defaults to
/// the wallet's own address — keeps the NFT *mutable* (the
/// wallet's payment key can later spend the ref UTXO and update
/// metadata). Pass an "always-fails" script address for
/// immutable NFTs.
#[serde(default)]
pub ref_address: Option<String>,
/// ADA to attach to the ref NFT output. Defaults to 1_500_000.
#[serde(default = "default_token_lovelace")]
pub ref_lovelace: u64,
/// Optional invalid-after slot for the auto-generated policy.
/// Omit for an open-ended single-sig policy bound to this
/// wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
/// Bypass the hard cap (`max_send_lovelace`) on the sum of
/// `user_lovelace + ref_lovelace`. Defaults small but a
/// user-confirmed large NFT mint can override.
#[serde(default)]
pub force: bool,
}
fn default_token_lovelace() -> u64 {
// 2.5 ADA — Babbage min-utxo for an inline-datum-bearing
// multi-asset output is ~1.79 ADA (depends on datum size).
// 1.5 was too low; 2.5 gives comfortable margin for typical
// CIP-68 metadata (~150 bytes). Larger metadata still requires
// the caller to override.
// Discovered preprod 2026-05-04 via
// BabbageOutputTooSmallUTxO chain rejection.
2_500_000
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct MintArgs {
/// Recipient bech32 address. Receives `dest_lovelace` ADA + the
/// freshly-minted asset. Often the wallet's own address.
pub dest_address: String,
/// ADA to attach to the mint output (must be ≥ min_utxo —
/// typically 1_500_000 lovelace for an asset-bearing output).
pub dest_lovelace: u64,
/// Hex-encoded asset name (raw bytes, 0-32 bytes).
pub asset_name_hex: String,
/// Mint quantity. Positive = mint, negative = burn (caller must
/// hold the assets to burn).
pub quantity: i64,
/// Optional invalid-after slot for the auto-generated policy.
/// If omitted, generates an open-ended single-sig policy bound
/// to the wallet's payment key.
#[serde(default)]
pub invalid_after_slot: Option<u64>,
/// Optional CIP-25 v2 metadata: a JSON object with the asset's
/// per-attributes (`name`, `image`, `description`, `mediaType`,
/// `files`, etc.). Wallets and explorers display this when
/// rendering the asset.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// Bypass the configured `max_send_lovelace` hard cap on
/// `dest_lovelace`. Only pass `true` for an intentional,
/// user-confirmed large mint output.
#[serde(default)]
pub force: bool,
}
#[tool(tool_box)]
impl WalletService {
#[tool(
name = "wallet_address",
description = "Return the wallet's primary base address (CIP-1852, account 0, index 0) as a bech32 string"
)]
async fn wallet_address(&self) -> String {
self.inner.address.clone()
}
#[tool(
name = "wallet_stake_address",
description = "Return the wallet's reward (stake) address as bech32 — `stake1...` on mainnet, `stake_test1...` on testnet. This is what gets pointed at a stake pool when delegating."
)]
async fn wallet_stake_address(&self) -> Result<String, String> {
self.inner
.stake_key
.stake_address(self.inner.network)
.map_err(|e| e.to_string())
}
#[tool(
name = "wallet_network",
description = "Return the configured Cardano network: mainnet, preview, or preprod"
)]
async fn wallet_network(&self) -> String {
match self.inner.network {
Network::Mainnet => "mainnet".into(),
Network::Preview => "preview".into(),
Network::Preprod => "preprod".into(),
}
}
#[tool(
name = "wallet_balance",
description = "Query ADA + native-asset balance at the wallet address. Returns JSON {lovelace, assets}."
)]
async fn wallet_balance(&self) -> Result<String, String> {
let bal = self
.inner
.chain
.get_balance(&self.inner.address)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&bal).map_err(|e| e.to_string())
}
#[tool(
name = "wallet_utxos",
description = "List UTXOs at the wallet address as a JSON array of {tx_hash, output_index, lovelace, assets}."
)]
async fn wallet_utxos(&self) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&utxos).map_err(|e| e.to_string())
}
#[tool(
name = "wallet_send",
description = "Build, sign, and submit a payment (ADA + optional native assets) from this wallet. Args: to_address (bech32), lovelace (u64), assets (optional array of {policy_id_hex, asset_name_hex, quantity}), force (bool, optional). Refuses sends > max_send_lovelace unless force=true. Returns the tx hash on success."
)]
async fn wallet_send(
&self,
#[tool(aggr)] SendArgs {
to_address,
lovelace,
assets,
datum_inline_cbor_hex,
force,
}: SendArgs,
) -> Result<String, String> {
if lovelace == 0 {
return Err("lovelace must be > 0".into());
}
// AUDIT4-G2 fix: catch sub-min-utxo ada-only sends client-side
// before the chain rejects them (saves a koios round-trip + the
// user's mental model of "tx submitted" → "tx failed minutes later").
// Asset-bearing sends have a dynamic min driven by asset count +
// name lengths — let those reach chain so the real number is in
// the error.
let default_min = ProtocolParams::default().min_utxo_lovelace;
if assets.is_empty() && lovelace < default_min {
return Err(format!(
"lovelace {lovelace} below min-utxo {default_min}; \
the chain would reject this output. Send ≥ {default_min} \
(1 ADA), or pass assets to use the dynamic asset-aware min."
));
}
self.enforce_value_cap(lovelace, force)?;
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let asset_specs: Vec<AssetSpec> = assets.into_iter().map(Into::into).collect();
let datum_bytes = match datum_inline_cbor_hex.as_deref() {
Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?),
None => None,
};
let cbor = build_signed_payment_with_assets(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&to_address,
lovelace,
&asset_specs,
datum_bytes.as_deref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_tx_status",
description = "Poll a submitted transaction's confirmation status. Args: tx_hash (hex). Returns JSON {status: confirmed|pending|not_found, num_confirmations?}. `confirmed` means the tx has at least 1 block built on top; `pending` means a node has accepted it but it's not yet in a block; `not_found` means no node knows about it (rejected, never submitted, or still propagating)."
)]
async fn wallet_tx_status(
&self,
#[tool(aggr)] TxStatusArgs { tx_hash }: TxStatusArgs,
) -> Result<String, String> {
let status = self
.inner
.chain
.tx_status(&tx_hash)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&status).map_err(|e| e.to_string())
}
#[tool(
name = "wallet_send_unsigned",
description = "Build a payment without signing or submitting. Returns JSON {cbor_hex, summary}: the unsigned tx CBOR for a cold-signer + a human-readable summary (predicted tx_hash, send/fee/change amounts). For high-value flows where the daemon must not auto-sign. After review + offline signing, submit the signed bytes via wallet_submit_signed_tx."
)]
async fn wallet_send_unsigned(
&self,
#[tool(aggr)] UnsignedSendArgs {
to_address,
lovelace,
assets,
datum_inline_cbor_hex,
}: UnsignedSendArgs,
) -> Result<String, String> {
if lovelace == 0 {
return Err("lovelace must be > 0".into());
}
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let asset_specs: Vec<AssetSpec> = assets.into_iter().map(Into::into).collect();
let datum_bytes = match datum_inline_cbor_hex.as_deref() {
Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?),
None => None,
};
let unsigned = build_unsigned_payment_with_assets(
self.inner.network,
&inputs,
&self.inner.address,
&to_address,
lovelace,
&asset_specs,
datum_bytes.as_deref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build: {e}"))?;
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
}
#[tool(
name = "wallet_submit_signed_tx",
description = "Submit a pre-signed transaction. Args: signed_cbor_hex (hex-encoded signed tx CBOR from a cold-signer). Returns the on-chain tx hash on success. Use after wallet_send_unsigned + offline signing."
)]
async fn wallet_submit_signed_tx(
&self,
#[tool(aggr)] SubmitSignedArgs { signed_cbor_hex }: SubmitSignedArgs,
) -> Result<String, String> {
let bytes = hex_decode(&signed_cbor_hex).map_err(|e| format!("decode: {e}"))?;
self.inner
.chain
.submit_tx(&bytes)
.await
.map_err(|e| format!("submit: {e}"))
}
#[tool(
name = "wallet_policy_create",
description = "Generate a single-sig native policy bound to this wallet's payment key. Args: invalid_after_slot (optional u64 — omit for open-ended, supply for time-locked supply). Returns JSON {policy_id_hex, script_cbor_hex, type}."
)]
async fn wallet_policy_create(
&self,
#[tool(aggr)] PolicyCreateArgs { invalid_after_slot }: PolicyCreateArgs,
) -> Result<String, String> {
let policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let policy_id = policy.policy_id().map_err(|e| e.to_string())?;
let cbor = policy.to_cbor().map_err(|e| e.to_string())?;
let mut policy_id_hex = String::with_capacity(56);
for b in policy_id.as_ref() {
policy_id_hex.push_str(&format!("{:02x}", b));
}
let mut cbor_hex = String::with_capacity(cbor.len() * 2);
for b in &cbor {
cbor_hex.push_str(&format!("{:02x}", b));
}
let kind = match invalid_after_slot {
Some(_) => "single_sig_timelock",
None => "single_sig",
};
Ok(format!(
"{{\"policy_id_hex\":\"{policy_id_hex}\",\"script_cbor_hex\":\"{cbor_hex}\",\"type\":\"{kind}\"}}"
))
}
#[tool(
name = "wallet_mint",
description = "Mint or burn a native asset under a wallet-generated single-sig policy, optionally with CIP-25 v2 metadata. Args: dest_address, dest_lovelace (≥ 1.5 ADA for asset-bearing UTXO), asset_name_hex, quantity (positive=mint, negative=burn), invalid_after_slot (optional), metadata (optional CIP-25 JSON object: {name, image, description, mediaType, files, ...}). Returns the tx hash on success."
)]
async fn wallet_mint(
&self,
#[tool(aggr)] MintArgs {
dest_address,
dest_lovelace,
asset_name_hex,
quantity,
invalid_after_slot,
metadata,
force,
}: MintArgs,
) -> Result<String, String> {
if quantity == 0 {
return Err("quantity must be nonzero (positive=mint, negative=burn)".into());
}
if dest_lovelace < 1_000_000 {
return Err(format!(
"dest_lovelace {dest_lovelace} below 1 ADA min — token-bearing UTXO will be rejected"
));
}
// HIGH-1 audit fix: enforce hard cap on lovelace going to a
// potentially-non-wallet destination.
self.enforce_value_cap(dest_lovelace, force)?;
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let cbor = build_signed_mint_with_metadata(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&dest_address,
dest_lovelace,
&policy,
&asset_name_hex,
quantity,
metadata.as_ref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign mint: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_mint_cip68_nft",
description = "Mint a CIP-68 NFT pair (label 100 ref + label 222 user) under a wallet-generated single-sig policy. Args: user_address, name_body_hex (raw asset-name body, ≤28 bytes), metadata (JSON object: name, image, description, mediaType, files, ...), user_lovelace (defaults 1.5 ADA), ref_address (defaults wallet — mutable NFT), ref_lovelace (defaults 1.5 ADA), invalid_after_slot? Returns the tx hash."
)]
async fn wallet_mint_cip68_nft(
&self,
#[tool(aggr)] Cip68NftArgs {
user_address,
user_lovelace,
name_body_hex,
metadata,
ref_address,
ref_lovelace,
invalid_after_slot,
force,
}: Cip68NftArgs,
) -> Result<String, String> {
if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 {
return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into());
}
// HIGH-1: cap on the total lovelace that leaves the wallet
// toward non-self destinations. Sum the two outputs; if either
// overflows, also reject.
let total = user_lovelace
.checked_add(ref_lovelace)
.ok_or("user_lovelace + ref_lovelace overflow")?;
self.enforce_value_cap(total, force)?;
let name_body = hex_decode(&name_body_hex).map_err(|e| format!("name_body_hex: {e}"))?;
if name_body.len() > 28 {
return Err(format!(
"name_body too long: {} bytes (max 28; CIP-68 prefix needs 4)",
name_body.len()
));
}
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let policy = match invalid_after_slot {
Some(slot) => PolicySpec::single_sig_timelock(&self.inner.payment_key, slot),
None => PolicySpec::single_sig(&self.inner.payment_key),
};
let ref_addr = ref_address.unwrap_or_else(|| self.inner.address.clone());
let cbor = build_signed_cip68_nft_mint(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&user_address,
user_lovelace,
&ref_addr,
ref_lovelace,
&name_body,
&metadata,
&policy,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign cip68 mint: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_script_spend",
description = "Spend a Plutus-locked UTXO. Args: locked_tx_hash, locked_output_index, locked_lovelace, plutus_version (v1|v2|v3), script_cbor_hex, redeemer_cbor_hex, witness_datum_hex (optional, omit if datum is inline on the locked utxo), payout_address, payout_lovelace, ex_units (optional {mem,steps} — defaults to a generous budget for trivial validators). Wallet picks its own collateral UTXO (≥ 5 ADA). Returns the tx hash."
)]
async fn wallet_script_spend(
&self,
#[tool(aggr)] ScriptSpendArgs {
locked_tx_hash,
locked_output_index,
locked_lovelace,
plutus_version,
script_cbor_hex,
redeemer_cbor_hex,
witness_datum_hex,
payout_address,
payout_lovelace,
ex_units,
force,
}: ScriptSpendArgs,
) -> Result<String, String> {
// HIGH-1: cap on payout_lovelace (the funds going to the
// potentially-non-wallet payout address).
self.enforce_value_cap(payout_lovelace, force)?;
let version = match plutus_version.to_ascii_lowercase().as_str() {
"v1" => PlutusVersion::V1,
"v2" => PlutusVersion::V2,
"v3" => PlutusVersion::V3,
other => {
return Err(format!(
"plutus_version must be v1, v2, or v3 (got {other:?})"
))
}
};
let script_cbor = hex_decode(&script_cbor_hex).map_err(|e| format!("script: {e}"))?;
let redeemer_cbor = hex_decode(&redeemer_cbor_hex).map_err(|e| format!("redeemer: {e}"))?;
let datum_cbor: Option<Vec<u8>> = witness_datum_hex
.as_deref()
.map(|s| hex_decode(s).map_err(|e| format!("datum: {e}")))
.transpose()?;
let budget = ex_units
.map(|e| PlutusExUnits {
mem: e.mem,
steps: e.steps,
})
.unwrap_or(DEFAULT_EX_UNITS);
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund for collateral first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let locked = PlutusInput {
tx_hash_hex: locked_tx_hash,
output_index: locked_output_index,
lovelace: locked_lovelace,
};
// Plutus paths require the V3 cost model so the chain can
// verify script_data_hash. Hardcoded preprod value from
// koios epoch_params; future improvement is pulling fresh
// from the chain on each call. (PLUTUS-4 audit fix.)
let params = ProtocolParams {
plutus_v3_cost_model: Some(PLUTUS_V3_COST_MODEL_PREPROD.to_vec()),
..ProtocolParams::default()
};
let cbor = build_signed_plutus_spend(
&self.inner.payment_key,
self.inner.network,
&locked,
version,
&script_cbor,
&redeemer_cbor,
datum_cbor.as_deref(),
&inputs,
&self.inner.address,
&payout_address,
payout_lovelace,
budget,
&params,
)
.map_err(|e| format!("build/sign plutus spend: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_stake_delegate",
description = "Delegate this wallet's stake to a Cardano pool. Args: pool_id (bech32 'pool1...'), register_first (bool, defaults true — prepends a 2 ADA stake-registration cert; set false if the stake key is already registered). Signs with both the payment and stake keys, submits, returns the tx hash."
)]
async fn wallet_stake_delegate(
&self,
#[tool(aggr)] StakeDelegateArgs {
pool_id,
register_first,
}: StakeDelegateArgs,
) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = build_signed_stake_delegation(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
&pool_id,
register_first,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign delegation: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_vote_delegate",
description = "Conway: delegate this wallet's voting power to a DRep. Args: drep (bech32 'drep1...' / 'drep_script1...' / 'abstain' / 'no_confidence'), register_first (bool, default true — adds 2 ADA stake-registration cert if needed). Signs with payment + stake keys, submits, returns tx hash."
)]
async fn wallet_vote_delegate(
&self,
#[tool(aggr)] VoteDelegateArgs {
drep,
register_first,
}: VoteDelegateArgs,
) -> Result<String, String> {
let target = aldabra_core::governance::parse_drep_target(&drep)
.map_err(|e| format!("parse drep: {e}"))?;
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_vote_delegation(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
target,
register_first,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign vote delegation: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_drep_register",
description = "Conway: register this wallet's stake credential as a DRep. Costs the protocol-defined 500 ADA deposit (refunded on deregistration). Args: anchor_url (optional CIP-100/119 metadata URL), anchor_data_hash_hex (optional 64-char blake2b-256 of anchor content). Both anchor fields must be set or both omitted. Returns submitted tx hash."
)]
async fn wallet_drep_register(
&self,
#[tool(aggr)] DrepRegisterArgs {
anchor_url,
anchor_data_hash_hex,
}: DrepRegisterArgs,
) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — fund the wallet first",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_drep_registration(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
anchor_url.as_deref(),
anchor_data_hash_hex.as_deref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign drep register: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_drep_deregister",
description = "Conway: deregister this wallet's DRep, refunding the 500 ADA deposit. No args. Returns submitted tx hash."
)]
async fn wallet_drep_deregister(
&self,
#[tool(aggr)] _args: DrepDeregisterArgs,
) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {} — need at least one to fund the fee",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_drep_deregistration(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign drep deregister: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_mint_unsigned",
description = "Build a mint TX without signing — for cold-sign or multi-sig flows. Args: dest_address, dest_lovelace, asset_name_hex, quantity, policy (optional, defaults to wallet single-sig; pass {type:'nofk',n:2,signer_pkhs_hex:[..]} for multi-sig treasury), metadata (optional CIP-25), disclosed_signer_pkh_hex (optional, defaults to wallet's pkh). Returns JSON {cbor_hex, summary}. Pass through wallet_sign_partial chain, then wallet_submit_signed_tx."
)]
async fn wallet_mint_unsigned(
&self,
#[tool(aggr)] MintUnsignedArgs {
dest_address,
dest_lovelace,
asset_name_hex,
quantity,
policy,
metadata,
disclosed_signer_pkh_hex,
}: MintUnsignedArgs,
) -> Result<String, String> {
if quantity == 0 {
return Err("quantity must be nonzero".into());
}
if dest_lovelace < 1_000_000 {
return Err(format!(
"dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO"
));
}
// Resolve PolicySpec — caller-supplied JSON or wallet default.
let policy_spec: PolicySpec = match policy {
Some(v) => serde_json::from_value(v)
.map_err(|e| format!("policy: {e}"))?,
None => PolicySpec::single_sig(&self.inner.payment_key),
};
// Resolve disclosed signer pkh.
let pkh_hex = match disclosed_signer_pkh_hex {
Some(h) => h,
None => {
let h = self.inner.payment_key.public_key_hash();
let mut s = String::with_capacity(56);
for b in h.as_ref() {
s.push_str(&format!("{:02x}", b));
}
s
}
};
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!(
"no utxos at wallet address {}",
self.inner.address
));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let unsigned = build_unsigned_mint(
self.inner.network,
&pkh_hex,
&inputs,
&self.inner.address,
&dest_address,
dest_lovelace,
&policy_spec,
&asset_name_hex,
quantity,
metadata.as_ref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build unsigned mint: {e}"))?;
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
}
#[tool(
name = "wallet_tx_summary",
description = "Decode a Conway-era tx CBOR (unsigned, partial, or signed) into a human-reviewable JSON summary: tx_hash, inputs count, outputs (address+lovelace+assets+inline_datum flag), fee, certificates, mint, witness count, aux-data presence. **Read-only — does not sign or submit.** Run this before `wallet_sign_partial` on any CBOR you didn't build yourself."
)]
async fn wallet_tx_summary(
&self,
#[tool(aggr)] TxSummaryArgs { cbor_hex }: TxSummaryArgs,
) -> Result<String, String> {
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
let summary = summarize_tx(&bytes).map_err(|e| format!("summarize: {e}"))?;
serde_json::to_string(&summary).map_err(|e| e.to_string())
}
#[tool(
name = "wallet_sign_partial",
description = "Append this wallet's VKeyWitness to a Conway-era tx (unsigned or partially-signed). Args: cbor_hex (hex-encoded tx CBOR). Returns the updated CBOR hex with our signature added. For multi-sig flows (e.g. a 2-of-2 treasury): each party calls this in turn, then any party submits via wallet_submit_signed_tx."
)]
async fn wallet_sign_partial(
&self,
#[tool(aggr)] SignPartialArgs { cbor_hex }: SignPartialArgs,
) -> Result<String, String> {
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
let updated = add_witness(&self.inner.payment_key, &bytes)
.map_err(|e| format!("sign: {e}"))?;
let mut hex = String::with_capacity(updated.len() * 2);
for b in &updated {
hex.push_str(&format!("{:02x}", b));
}
Ok(hex)
}
// ===========================================================
// chain_* — read-only Koios passthroughs.
//
// No signing, no key access. Same network as the configured
// wallet (mainnet/preprod/preview routed via ALDABRA_KOIOS_BASE).
// Returns Koios JSON verbatim — caller parses what they need.
//
// Saves the bash-curl friction when looking at addresses/txs/
// pools/etc that aren't this wallet specifically.
// ===========================================================
#[tool(
name = "chain_tx_info",
description = "Full Koios `tx_info` for a transaction hash — inputs, outputs, certs, mint, metadata, plutus_contracts, withdrawals, etc. Heavy response (multi-MB possible on complex txs). Args: tx_hash (hex). Returns the raw Koios JSON."
)]
async fn chain_tx_info(
&self,
#[tool(aggr)] ChainTxInfoArgs { tx_hash }: ChainTxInfoArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_tx_hashes": [tx_hash]});
self.inner
.chain
.post_raw_json("tx_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_address_info",
description = "Full Koios `address_info` for any address — balance + utxo set + stake address. Use this to look up addresses other than this wallet (this wallet has the dedicated wallet_balance / wallet_utxos)."
)]
async fn chain_address_info(
&self,
#[tool(aggr)] ChainAddressArgs { address }: ChainAddressArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_addresses": [address]});
self.inner
.chain
.post_raw_json("address_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_pool_list",
description = "Filter the Koios pool list. Args: ticker (optional, exact match — e.g. \"AHL\"), pool_id (optional bech32). Returns array of matching pools with margin/pledge/relay info. Empty filter set returns all active pools (large)."
)]
async fn chain_pool_list(
&self,
#[tool(aggr)] ChainPoolListArgs { ticker, pool_id }: ChainPoolListArgs,
) -> Result<String, String> {
let mut q: Vec<(String, String)> = Vec::new();
if let Some(t) = ticker {
q.push(("ticker".into(), format!("eq.{t}")));
}
if let Some(p) = pool_id {
q.push(("pool_id_bech32".into(), format!("eq.{p}")));
}
let q_refs: Vec<(&str, &str)> = q.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
self.inner
.chain
.get_raw_json("pool_list", &q_refs)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_pool_info",
description = "Detailed Koios `pool_info` for one or more pools. Args: pool_ids (array of bech32 pool ids). Returns delegators, live stake, recent blocks, metadata."
)]
async fn chain_pool_info(
&self,
#[tool(aggr)] ChainPoolInfoArgs { pool_ids }: ChainPoolInfoArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_pool_bech32_ids": pool_ids});
self.inner
.chain
.post_raw_json("pool_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_epoch_params",
description = "Koios `epoch_params` for the current (or specified) epoch — protocol params: cost models, fees a/b, deposits, ex_units prices, max tx size, etc. Args: epoch_no (optional u64; latest if omitted)."
)]
async fn chain_epoch_params(
&self,
#[tool(aggr)] ChainEpochArgs { epoch_no }: ChainEpochArgs,
) -> Result<String, String> {
let mut q: Vec<(String, String)> = Vec::new();
if let Some(e) = epoch_no {
q.push(("_epoch_no".into(), e.to_string()));
}
let q_refs: Vec<(&str, &str)> = q.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
self.inner
.chain
.get_raw_json("epoch_params", &q_refs)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_asset_info",
description = "Koios `asset_info` for a native asset. Args: policy_id_hex (56 hex chars), asset_name_hex (hex of raw asset-name bytes). Returns supply, holders, mint history, metadata."
)]
async fn chain_asset_info(
&self,
#[tool(aggr)] ChainAssetArgs {
policy_id_hex,
asset_name_hex,
}: ChainAssetArgs,
) -> Result<String, String> {
let body = serde_json::json!({
"_asset_list": [[policy_id_hex, asset_name_hex]]
});
self.inner
.chain
.post_raw_json("asset_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_account_info",
description = "Koios `account_info` for a stake address. Args: stake_address (bech32 stake1...). Returns registered/active state, delegated_pool, rewards balance, total stake."
)]
async fn chain_account_info(
&self,
#[tool(aggr)] ChainAccountArgs { stake_address }: ChainAccountArgs,
) -> Result<String, String> {
let body = serde_json::json!({"_stake_addresses": [stake_address]});
self.inner
.chain
.post_raw_json("account_info", &body)
.await
.map_err(|e| format!("koios: {e}"))
}
#[tool(
name = "chain_tip",
description = "Koios `tip` — current chain tip: block height, slot, epoch, hash, timestamp. Useful for absolute-time anchoring + stale-data detection."
)]
async fn chain_tip(&self) -> Result<String, String> {
self.inner
.chain
.get_raw_json("tip", &[])
.await
.map_err(|e| format!("koios: {e}"))
}
// ─── DAO management — `$ALDABRA_DATA/daos/` config files ─────────────────
//
// These are filesystem-only — no chain calls. Cheap to invoke; users can
// register Bob's DAO + Alice's DAO + Sulkta in one session and switch
// between them with `dao_use`.
#[tool(
name = "dao_register",
description = "Register a DAO config (Sulkta, Bob's, etc) at $ALDABRA_DATA/daos/<name>.json. First-registered DAO becomes active automatically. Required: name (lowercase letters/digits/underscore/dash), governor_addr, stakes_addr, treasury_addr (all bech32), gov_token_policy (56 hex chars), gov_token_name_hex (hex of asset name), initial_spend (txhash#index, the Agora bootstrap tx ref), max_cosigners (u32), treasury_ref_config (56 hex chars). Optional: description, network (mainnet/preprod/preview, default mainnet), and Phase-4 prerequisites — proposal_addr, stake_st_policy, proposal_st_policy (56 hex), plus reference UTxO refs (`txhash#index`) for governor_validator / stake_validator / proposal_validator / stake_st_policy / proposal_st_policy. The optional Phase-4 fields can be populated now or later via dao_discover_scripts (when shipped)."
)]
async fn dao_register(
&self,
#[tool(aggr)] DaoRegisterArgs {
name,
description,
governor_addr,
stakes_addr,
treasury_addr,
gov_token_policy,
gov_token_name_hex,
initial_spend,
max_cosigners,
treasury_ref_config,
network,
proposal_addr,
stake_st_policy,
proposal_st_policy,
governor_validator_ref,
stake_validator_ref,
proposal_validator_ref,
stake_st_policy_ref,
proposal_st_policy_ref,
}: DaoRegisterArgs,
) -> Result<String, String> {
let cfg = DaoConfig {
name: name.clone(),
description,
governor_addr,
stakes_addr,
treasury_addr,
gov_token_policy,
gov_token_name_hex,
initial_spend,
max_cosigners,
treasury_ref_config,
network: match network.as_deref() {
Some("preprod") => DaoNetwork::Preprod,
Some("preview") => DaoNetwork::Preview,
_ => DaoNetwork::Mainnet,
},
proposal_addr,
stake_st_policy,
proposal_st_policy,
script_refs: ScriptRefs {
governor_validator: governor_validator_ref,
stake_validator: stake_validator_ref,
proposal_validator: proposal_validator_ref,
treasury_validator: None,
stake_st_policy: stake_st_policy_ref,
proposal_st_policy: proposal_st_policy_ref,
},
};
self.inner
.dao_store
.register(&cfg)
.map_err(|e| e.to_string())?;
Ok(format!("registered DAO {name:?}"))
}
#[tool(
name = "dao_list",
description = "List all registered DAO config names (sorted) plus the currently active one. Returns JSON {active: \"<name>\"|null, all: [...]}."
)]
async fn dao_list(&self) -> Result<String, String> {
let all = self
.inner
.dao_store
.list()
.map_err(|e| e.to_string())?;
let active = self.inner.dao_store.get_active().ok().map(|a| a.name().to_string());
Ok(serde_json::json!({ "active": active, "all": all }).to_string())
}
#[tool(
name = "dao_use",
description = "Set the active DAO. Subsequent dao_* calls without an explicit `dao` arg target this one. Must already be registered. Args: name (string)."
)]
async fn dao_use(
&self,
#[tool(aggr)] DaoUseArgs { name }: DaoUseArgs,
) -> Result<String, String> {
self.inner
.dao_store
.set_active(&name)
.map_err(|e| e.to_string())?;
Ok(format!("active DAO is now {name:?}"))
}
#[tool(
name = "dao_remove",
description = "Delete a registered DAO config. If it was the active DAO, clears active. Doesn't touch chain — the DAO continues to exist on Cardano. Args: name (string)."
)]
async fn dao_remove(
&self,
#[tool(aggr)] DaoUseArgs { name }: DaoUseArgs,
) -> Result<String, String> {
self.inner
.dao_store
.remove(&name)
.map_err(|e| e.to_string())?;
Ok(format!("removed DAO {name:?}"))
}
#[tool(
name = "dao_show",
description = "Return the full DaoConfig for a named DAO (or the active one if `dao` is omitted). Returns JSON of every config field — useful for audit + seeing what's wired up. Args: dao (optional string)."
)]
async fn dao_show(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
serde_json::to_string(&cfg).map_err(|e| format!("serialize: {e}"))
}
// ─── DAO live-state reads ────────────────────────────────────────────────
#[tool(
name = "dao_governor_state",
description = "Read the live GovernorDatum for a DAO. Returns the singleton governor UTxO ref + decoded thresholds (execute/create/toVoting/vote/cosign GT amounts), nextProposalId, timing config (draft/voting/locking/executing periods in ms), and the per-stake proposal-creation cap. Args: dao (optional — defaults to active)."
)]
async fn dao_governor_state(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let (utxo_ref, datum) = self
.inner
.dao_reader
.get_governor(&cfg)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"dao": cfg.name,
"governor_utxo": utxo_ref,
"next_proposal_id": datum.next_proposal_id,
"thresholds": {
"execute": datum.proposal_thresholds.execute,
"create": datum.proposal_thresholds.create,
"to_voting": datum.proposal_thresholds.to_voting,
"vote": datum.proposal_thresholds.vote,
"cosign": datum.proposal_thresholds.cosign,
},
"timing_ms": {
"draft": datum.proposal_timings.draft_time,
"voting": datum.proposal_timings.voting_time,
"locking": datum.proposal_timings.locking_time,
"executing": datum.proposal_timings.executing_time,
"min_stake_voting": datum.proposal_timings.min_stake_voting_time,
"voting_time_range_max_width": datum.proposal_timings.voting_time_range_max_width,
},
"create_proposal_time_range_max_width_ms": datum.create_proposal_time_range_max_width,
"max_proposals_per_stake": datum.maximum_created_proposals_per_stake,
})
.to_string())
}
#[tool(
name = "dao_stake_list",
description = "List all live stakes for a DAO (filtered by gov-token policy — the shared MLabs stakes addr serves many DAOs). Returns JSON array of {utxo_ref, owner_pkh_hex, owner_kind (\"PubKey\"|\"Script\"), staked_amount, gov_token_quantity, lovelace, delegated_to_pkh_hex, locked_by: [...]}. Args: dao (optional — defaults to active)."
)]
async fn dao_stake_list(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let stakes = self
.inner
.dao_reader
.list_stakes(&cfg)
.await
.map_err(|e| e.to_string())?;
let arr: Vec<serde_json::Value> = stakes
.into_iter()
.map(|s| stake_utxo_to_json(&s))
.collect();
Ok(serde_json::json!({ "dao": cfg.name, "stakes": arr }).to_string())
}
#[tool(
name = "dao_discover_scripts",
description = "Auto-populate a DAO's ScriptRefs + StakeST policy by inspecting on-chain state. v1 fills in: governor_validator_ref, stake_validator_ref, stake_st_policy, stake_st_policy_ref. proposal_addr + proposal_st_policy still require manual entry (v1 limitation). Searches the MLabs shared Agora deployer (`addr1w9gexmeunzsy...`) by default; pass extra deployer addresses if your DAO's scripts live elsewhere. Args: dao (optional), extra_deployers (optional list of bech32). Returns JSON {discovered, gaps, updated_config}."
)]
async fn dao_discover_scripts(
&self,
#[tool(aggr)] DaoDiscoverArgs {
dao,
extra_deployers,
}: DaoDiscoverArgs,
) -> Result<String, String> {
let mut cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
// Build the deployer search list: MLabs's shared one + any caller-supplied.
let extra: Vec<String> = extra_deployers.unwrap_or_default();
let mut deployers: Vec<&str> = vec![MAINNET_AGORA_SHARED_DEPLOYER];
deployers.extend(extra.iter().map(|s| s.as_str()));
// Use the same Koios base URL as the wallet's chain backend.
let client = KoiosDiscoveryClient::new(self.inner.koios_base.clone());
let report = discover_scripts(&cfg, &client, &deployers)
.await
.map_err(|e| e.to_string())?;
apply_discovery(&mut cfg, &report);
// Persist.
self.inner
.dao_store
.register(&cfg)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"dao": cfg.name,
"discovered": {
"stake_st_policy": report.stake_st_policy,
"governor_validator_ref": report.governor_validator_ref,
"stake_validator_ref": report.stake_validator_ref,
"stake_st_policy_ref": report.stake_st_policy_ref,
},
"gaps": report.gaps,
"config_after": cfg,
})
.to_string())
}
#[tool(
name = "dao_proposal_create_unsigned",
description = "Build (but DO NOT submit) an unsigned proposal-creation tx for the given DAO. Returns the CBOR-hex of the unsigned tx body + the new proposal_id. Currently supports InfoOnly proposals only — TreasuryWithdrawal effect path lands in Phase 4c. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx. Args: dao (optional — defaults to active), fee_lovelace (suggested ~3_000_000 for v1; refine via koios tx_evaluate), starting_time_ms (POSIX millis to embed in ProposalDatum.starting_time; pass current chain tip's slot * 1000 + epoch start)."
)]
async fn dao_proposal_create_unsigned(
&self,
#[tool(aggr)] DaoProposalCreateArgs {
dao,
fee_lovelace,
starting_time_ms,
}: DaoProposalCreateArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
// Read live governor state so we have the current next_proposal_id +
// datum to copy.
let (governor_utxo_ref, governor_datum) = self
.inner
.dao_reader
.get_governor(&cfg)
.await
.map_err(|e| e.to_string())?;
let (gov_tx_hash, gov_idx) = parse_utxo_ref(&governor_utxo_ref)?;
// Pull governor lovelace + GST asset id from the same utxo.
let governor_utxo = self
.inner
.chain
.get_utxos(&cfg.governor_addr)
.await
.map_err(|e| format!("koios get governor utxos: {e}"))?
.into_iter()
.find(|u| u.tx_hash == gov_tx_hash && u.output_index == gov_idx)
.ok_or_else(|| format!("governor utxo {governor_utxo_ref} no longer present on chain"))?;
let gov_lovelace = governor_utxo.lovelace;
// Extract GST policy + name from the governor utxo's asset_list.
// Sulkta's GST has empty asset name; one asset on the utxo (qty=1) IS the GST.
let (gst_policy_hex, gst_asset_name_hex) = governor_utxo
.assets
.iter()
.next()
.map(|(k, _)| {
if k.len() < 56 {
return ("".to_string(), "".to_string());
}
let (p, n) = k.split_at(56);
(p.to_string(), n.to_string())
})
.ok_or_else(|| {
"governor UTxO has no GST asset — chain state inconsistent".to_string()
})?;
if gst_policy_hex.is_empty() {
return Err("governor UTxO asset key malformed (< 56 chars)".into());
}
// Find the proposer's stake at stakes_addr via dao_reader.list_stakes.
// Match on owner pkh.
let proposer_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 == &proposer_pkh,
_ => false,
})
.ok_or_else(|| {
format!(
"no stake at stakes_addr owned by this wallet's pkh {}\
proposer must have a registered stake first",
hex::encode(&proposer_pkh)
)
})?;
let (stake_tx, stake_idx) = parse_utxo_ref(&my_stake.utxo_ref)?;
// Pull StakeST asset name from the stake utxo.
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 — for tx 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}"))?;
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}"))?;
// AUDIT-H5 fix: assets in the chain backend are
// `BTreeMap<policy_id_hex || asset_name_hex, qty>`. Previous
// implementation silently dropped any key < 56 chars via filter_map
// — that could let a corrupt Koios response burn assets on submit.
// Now: any malformed key surfaces as an explicit error.
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,
});
}
out
};
// ScriptRefs must be populated before this tool can build a tx.
let governor_validator_ref = ReferenceUtxo::from_str(
cfg.script_refs
.governor_validator
.as_deref()
.ok_or_else(|| {
"DaoConfig.script_refs.governor_validator missing — \
run dao_discover_scripts first".to_string()
})?,
)
.map_err(|e| e.to_string())?;
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_st_policy_ref = ReferenceUtxo::from_str(
cfg.script_refs
.proposal_st_policy
.as_deref()
.ok_or_else(|| {
"DaoConfig.script_refs.proposal_st_policy missing".to_string()
})?,
)
.map_err(|e| e.to_string())?;
let unsigned = build_unsigned_proposal_create(ProposalCreateArgs {
cfg: cfg.clone(),
governor: GovernorUtxoIn {
tx_hash_hex: gov_tx_hash,
output_index: gov_idx,
lovelace: gov_lovelace,
datum: governor_datum,
gst_policy_hex,
gst_asset_name_hex,
},
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,
},
proposer_pkh,
change_address: self.inner.address.clone(),
wallet_utxos,
starting_time_ms,
tip_slot,
governor_validator_ref,
stake_validator_ref,
proposal_st_policy_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,
"new_proposal_id": unsigned.new_proposal_id,
"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_stake_destroy_unsigned",
description = "Build an unsigned tx that destroys this wallet's stake — burns the StakeST token and returns all locked governance tokens (TRP) + lovelace to the wallet. Owner-only (delegatees rejected). Requires the stake to have NO active locks (no Created/Voted/Cosigned ProposalLocks). Args: dao? + fee_lovelace (~2_000_000)."
)]
async fn dao_stake_destroy_unsigned(
&self,
#[tool(aggr)] DaoStakeDestroyArgs { dao, fee_lovelace }: DaoStakeDestroyArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let owner_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 == &owner_pkh,
_ => false,
})
.ok_or_else(|| {
format!(
"no stake at stakes_addr owned by this wallet's pkh {}",
hex::encode(&owner_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())?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
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 stake_st_policy_ref = ReferenceUtxo::from_str(
cfg.script_refs
.stake_st_policy
.as_deref()
.ok_or_else(|| {
"DaoConfig.script_refs.stake_st_policy missing — \
run dao_discover_scripts first".to_string()
})?,
)
.map_err(|e| e.to_string())?;
let unsigned = build_unsigned_stake_destroy(StakeDestroyArgs {
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,
},
owner_pkh,
change_address: self.inner.address.clone(),
wallet_utxos,
stake_validator_ref,
stake_st_policy_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,
"returned_gov_token_qty": unsigned.returned_gov_token_qty,
"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_advance_unsigned",
description = "Build an unsigned advance tx that pushes a proposal to its next status (Draft→VotingReady, VotingReady→Locked, or Locked→Finished — or to Finished from Draft/VotingReady when timing has expired). Caller picks the right transition from the proposal's current status + chain time. The Locked→Finished GAT-mint path (effected proposals) is Phase 4c-bis; for v1 only the InfoOnly Locked→Finished is supported. Args: dao? + proposal_id + fee_lovelace. The tool inspects current status, fetches cosigner stake refs as needed, and computes the right tx shape."
)]
async fn dao_proposal_advance_unsigned(
&self,
#[tool(aggr)] DaoProposalAdvanceArgs {
dao,
proposal_id,
fee_lovelace,
}: DaoProposalAdvanceArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
if !matches!(cfg.network, DaoNetwork::Mainnet) {
return Err(format!(
"dao_proposal_advance_unsigned only supports mainnet for v1 \
(current dao network: {:?})",
cfg.network
));
}
// 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)?;
// Tip slot + ms.
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 tip_ms = mainnet_slot_to_posix_ms(tip_slot)?;
// Compute the transition based on current status + tip time vs windows.
use aldabra_dao::agora::proposal::ProposalStatus as PS;
let st = target.datum.starting_time;
let tc = &target.datum.timing_config;
let drafting_end = st + tc.draft_time;
let voting_end = drafting_end + tc.voting_time;
let locking_end = voting_end + tc.locking_time;
let transition = match target.datum.status {
PS::Draft => {
if tip_ms < drafting_end {
AdvanceTransition::DraftToVotingReady
} else {
AdvanceTransition::DraftToFinished
}
}
PS::VotingReady => {
// Window for V→L is [voting_end, locking_end]. After that → Finished.
if tip_ms < locking_end {
AdvanceTransition::VotingReadyToLocked
} else {
AdvanceTransition::VotingReadyToFinished
}
}
PS::Locked => AdvanceTransition::LockedToFinished,
PS::Finished => {
return Err(format!(
"proposal #{} is already Finished — cannot advance further",
proposal_id
));
}
};
// For Draft→VotingReady, fetch all cosigner stakes by matching
// owner pkh against proposal.cosigners.
let mut cosigner_stake_refs = Vec::new();
if transition == AdvanceTransition::DraftToVotingReady {
let stakes = self
.inner
.dao_reader
.list_stakes(&cfg)
.await
.map_err(|e| e.to_string())?;
for cosigner in &target.datum.cosigners {
let cosigner_h = match cosigner {
aldabra_dao::agora::stake::Credential::PubKey(h) => h,
_ => {
return Err(
"script-credentialed cosigners not yet supported for advance".into(),
);
}
};
let s = stakes
.iter()
.find(|s| match &s.datum.owner {
aldabra_dao::agora::stake::Credential::PubKey(h) => h == cosigner_h,
_ => false,
})
.ok_or_else(|| {
format!(
"no on-chain stake found for cosigner pkh {}\
cosigner may have moved their stake or destroyed it",
hex::encode(cosigner_h)
)
})?;
let (s_tx, s_idx) = parse_utxo_ref(&s.utxo_ref)?;
cosigner_stake_refs.push(CosignerStakeRef {
tx_hash_hex: s_tx,
output_index: s_idx,
owner: s.datum.owner.clone(),
staked_amount: s.datum.staked_amount,
});
}
}
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 advancer_pkh = self.wallet_pkh()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let unsigned = build_unsigned_proposal_advance(ProposalAdvanceArgs {
cfg: cfg.clone(),
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,
},
transition,
cosigner_stake_refs,
proposal_validator_ref,
change_address: self.inner.address.clone(),
wallet_utxos,
advancer_pkh,
tip_slot,
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,
"from_status": format!("{:?}", unsigned.from_status),
"to_status": format!("{:?}", unsigned.to_status),
"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_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."
)]
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. Goes
// through the DaoReader which decodes inline datums + filters
// out orphan/non-proposal UTxOs.
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, proposal_addr)
})?;
let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?;
let prop_lovelace = target.lovelace;
let prop_st_name_hex = target.proposal_st_asset_name_hex;
let prop_datum = target.datum;
// 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)."
)]
async fn dao_my_stake(
&self,
#[tool(aggr)] DaoShowArgs { dao }: DaoShowArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let pkh = self.wallet_pkh()?;
let stakes = self
.inner
.dao_reader
.list_stakes(&cfg)
.await
.map_err(|e| e.to_string())?;
let mine: Vec<serde_json::Value> = stakes
.into_iter()
.filter(|s| match &s.datum.owner {
DaoCredential::PubKey(h) => h == &pkh,
DaoCredential::Script(_) => false,
})
.map(|s| stake_utxo_to_json(&s))
.collect();
Ok(serde_json::json!({
"dao": cfg.name,
"wallet_pkh": hex::encode(&pkh),
"stakes": mine,
})
.to_string())
}
}
// ─── DAO arg structs ────────────────────────────────────────────────────────
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoRegisterArgs {
/// Lowercase letters, digits, `_`, `-`. Becomes the filename.
pub name: String,
/// Free-form description for humans.
#[serde(default)]
pub description: Option<String>,
pub governor_addr: String,
pub stakes_addr: String,
pub treasury_addr: String,
/// 56 hex chars (28 bytes).
pub gov_token_policy: String,
/// Hex-encoded asset name (e.g. "546572726170696e" for "Terrapin").
pub gov_token_name_hex: String,
/// `txhash#index` — the Agora bootstrap tx ref that identifies the DAO.
pub initial_spend: String,
pub max_cosigners: u32,
/// 56 hex chars (28 bytes).
pub treasury_ref_config: String,
/// "mainnet" | "preprod" | "preview". Default mainnet.
#[serde(default)]
pub network: Option<String>,
// ─── Phase 4 prerequisites — all optional ─────────────────────────────
//
// Populate these to unlock dao_proposal_create_unsigned and the
// upcoming vote/cosign/advance tools. Each can be discovered via
// chain queries (the audit pattern at internal notes*.md);
// a future dao_discover_scripts MCP tool will fill them automatically.
/// Proposal validator address (bech32). Where new proposal UTxOs land.
#[serde(default)]
pub proposal_addr: Option<String>,
/// 56 hex chars — StakeST minting policy id.
#[serde(default)]
pub stake_st_policy: Option<String>,
/// 56 hex chars — ProposalST minting policy id.
#[serde(default)]
pub proposal_st_policy: Option<String>,
/// `txhash#index` reference UTxO carrying the governor validator script.
#[serde(default)]
pub governor_validator_ref: Option<String>,
/// `txhash#index` reference UTxO carrying the stake validator script.
#[serde(default)]
pub stake_validator_ref: Option<String>,
/// `txhash#index` reference UTxO carrying the proposal validator script.
#[serde(default)]
pub proposal_validator_ref: Option<String>,
/// `txhash#index` reference UTxO carrying the StakeST minting policy script.
#[serde(default)]
pub stake_st_policy_ref: Option<String>,
/// `txhash#index` reference UTxO carrying the ProposalST minting policy script.
#[serde(default)]
pub proposal_st_policy_ref: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoUseArgs {
pub name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoShowArgs {
/// Named DAO. Falls through to the active one if omitted.
#[serde(default)]
pub dao: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoDiscoverArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Extra deployer addresses to search (bech32) on top of the
/// default MLabs shared deployer. Useful for DAOs whose scripts
/// were deployed to a private address.
#[serde(default)]
pub extra_deployers: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoProposalCreateArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Estimated total fee in lovelace. v1 caller-supplied; future versions
/// will derive from `koios /tx_evaluate`. ~3 ADA is a reasonable bound
/// for an InfoOnly proposal-create on Sulkta-shape thresholds.
pub fee_lovelace: u64,
/// POSIX time in milliseconds to embed in the new ProposalDatum's
/// `starting_time`. Should reflect current chain tip — pass
/// `chain_tip.block_time * 1000` (Koios's `block_time` is in seconds).
pub starting_time_ms: i64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoStakeDestroyArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Estimated total fee. ~2_000_000 reasonable for a single-stake destroy.
pub fee_lovelace: u64,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoProposalAdvanceArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Proposal id to advance.
pub proposal_id: i64,
/// Estimated total fee in lovelace. ~2_500_000 reasonable.
pub fee_lovelace: u64,
}
#[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.
#[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())
}
/// 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
.split_once('#')
.ok_or_else(|| format!("utxo ref {s:?} not in 'txhash#index' form"))?;
let idx: u32 = i.parse().map_err(|e| format!("utxo index {i:?} parse: {e}"))?;
Ok((h.to_string(), idx))
}
/// 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
/// keep the dao crate's wire shape decoupled from the MCP tool surface — a
/// future Phase 4 may add fields to StakeUtxo that we don't want to expose.
fn stake_utxo_to_json(s: &aldabra_dao::reader::StakeUtxo) -> serde_json::Value {
use aldabra_dao::agora::stake::ProposalAction;
let (owner_kind, owner_pkh) = match &s.datum.owner {
DaoCredential::PubKey(h) => ("PubKey", hex::encode(h)),
DaoCredential::Script(h) => ("Script", hex::encode(h)),
};
let delegated = s.datum.delegated_to.as_ref().map(|c| match c {
DaoCredential::PubKey(h) => serde_json::json!({"kind":"PubKey","hex": hex::encode(h)}),
DaoCredential::Script(h) => serde_json::json!({"kind":"Script","hex": hex::encode(h)}),
});
let locks: Vec<serde_json::Value> = s
.datum
.locked_by
.iter()
.map(|l| {
let action = match &l.action {
ProposalAction::Created => serde_json::json!({"kind":"Created"}),
ProposalAction::Voted { result_tag, posix_time } => serde_json::json!({
"kind":"Voted","result_tag": result_tag, "posix_time_ms": posix_time,
}),
ProposalAction::Cosigned => serde_json::json!({"kind":"Cosigned"}),
};
serde_json::json!({
"proposal_id": l.proposal_id,
"action": action,
})
})
.collect();
serde_json::json!({
"utxo_ref": s.utxo_ref,
"owner_kind": owner_kind,
"owner_pkh_hex": owner_pkh,
"staked_amount": s.datum.staked_amount,
"gov_token_quantity": s.gov_token_quantity,
"lovelace": s.lovelace,
"delegated_to": delegated,
"locked_by": locks,
})
}
#[tool(tool_box)]
impl ServerHandler for WalletService {
fn get_info(&self) -> ServerInfo {
// Declare `tools` capability explicitly. rmcp 0.1.5's `#[tool(tool_box)]` macro
// does NOT backfill ServerInfo::capabilities; without this line Claude Code (and
// any spec-compliant MCP client) reads `"capabilities": {}` from the initialize
// response and skips `tools/list` entirely. The instructions field still lands,
// so the failure mode is silent — server appears connected, no tool surface.
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), Conway governance (wallet_vote_delegate to a DRep, wallet_drep_register / wallet_drep_deregister for becoming a DRep). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
),
..Default::default()
}
}
}
#[cfg(test)]
mod server_info_tests {
use super::*;
#[test]
fn capabilities_builder_declares_tools() {
// Regression for the silent "hasTools:false" bug. Claude Code reads
// `serverCapabilities.tools` from initialize and skips `tools/list`
// entirely if it's missing — leaving the server connected but with
// zero callable tools. If anyone refactors `get_info()` and drops
// the `enable_tools()` call, this test catches it.
let cap = ServerCapabilities::builder().enable_tools().build();
assert!(cap.tools.is_some(), "tools capability must be declared");
let wire = serde_json::to_value(&cap).expect("ServerCapabilities serializes");
assert!(
wire.get("tools").is_some(),
"wire-format capabilities must include 'tools' key, got: {wire}"
);
}
}