aldabra/crates/aldabra-mcp/src/tools.rs
Sulkta aa9b16995e fix(mcp): force type:object on Value-typed metadata/policy args
schemars derives an empty (no-type) JSON Schema for Option<serde_json::Value>
and serde_json::Value fields. Claude Code's MCP client interprets schema-
without-type as 'string-encoded' and JSON-stringifies the user's {...}
before sending — at which point the server-side validation
`value.is_object()` returns false and the tool errors with
'CIP-25 metadata must be a JSON object' / 'CIP-68 metadata must be a
JSON object'.

Surfaced during Track #37 E2E test (2026-05-09): wallet_mint and
wallet_mint_cip68_nft both rejected metadata-as-object args from
Claude Code while the SAME object via raw stdio MCP works fine —
proves the issue is client-side schema interpretation, not server.

Fix: add a json_object_schema helper that emits {type: object,
additionalProperties: true} and annotate every metadata + policy
field with #[schemars(schema_with = "json_object_schema")].
Affected fields:
- MintArgs.metadata
- MintUnsignedArgs.policy + .metadata
- Cip68NftArgs.metadata

additionalProperties is left wide-open since these args really do
accept arbitrary keys (CIP-25/CIP-68 are open-ended schemas).
2026-05-09 09:23:34 -07:00

3922 lines
158 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::proposal_retract_votes::{
build_unsigned_proposal_retract_votes, ProposalRetractVotesArgs,
};
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_extras, build_signed_plutus_spend, build_signed_stake_delegation,
build_unsigned_mint, build_unsigned_payment_extras, build_unsigned_plutus_mint, hex_decode,
summarize_tx, AssetSpec, ExtraDestAsset, InputUtxo, Network, PaymentKey, PlutusExUnits,
PlutusInput, PlutusMintArgs as CorePlutusMintArgs, PlutusMintAsset, PlutusVersion, PolicySpec,
ProtocolParams, ReferenceScriptSpec, ScriptKind, StakeKey, DEFAULT_EX_UNITS,
};
/// Resolve a reference-script bytestring from EITHER an inline hex
/// argument OR a file path inside the container. Caller passes both
/// raw options; this fn enforces the "at most one" rule and reads
/// the file when path is set.
///
/// The path-based variant exists because of the 2026-05-07 MCP
/// transport bug: hex strings >~ 4500 chars get a 1-byte truncation
/// + structural rearrangement somewhere between Claude Code and
/// aldabra's stdio reader. Reading from a file inside the container
/// bypasses the JSON-RPC arg path entirely.
fn resolve_ref_script_bytes(
cbor_hex: Option<&str>,
path: Option<&str>,
) -> Result<Option<Vec<u8>>, String> {
match (cbor_hex, path) {
(Some(_), Some(_)) => Err(
"set at most one of reference_script_cbor_hex / reference_script_path".into(),
),
(Some(s), None) => {
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
Ok(Some(hex_decode(&cleaned).map_err(|e| {
format!("decode reference_script_cbor_hex: {e}")
})?))
}
(None, Some(p)) => {
let raw = std::fs::read_to_string(p)
.map_err(|e| format!("read reference_script_path '{p}': {e}"))?;
let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
if cleaned.is_empty() {
return Err(format!(
"reference_script_path '{p}' contained no hex characters"
));
}
Ok(Some(hex_decode(&cleaned).map_err(|e| {
format!("decode reference_script_path '{p}' contents: {e}")
})?))
}
(None, None) => Ok(None),
}
}
/// Resolve the Plutus minting-policy CBOR from EITHER an inline
/// hex argument OR a file path inside the container. Caller passes
/// both raw options; this fn enforces the "exactly one" rule and
/// reads the file when path is set.
///
/// Mirrors [`resolve_ref_script_bytes`] — same workaround for the
/// 2026-05-07 MCP transport bug where hex strings >~ 4500 chars
/// get a 1-byte truncation between Claude Code and aldabra's stdio
/// reader, surfacing as "odd length" hex decode errors and blocking
/// debug-build minting policies. Reading from a file inside the
/// container bypasses the JSON-RPC arg path entirely.
fn resolve_policy_cbor_bytes(
cbor_hex: Option<&str>,
path: Option<&str>,
) -> Result<Vec<u8>, String> {
match (cbor_hex, path) {
(Some(_), Some(_)) => Err(
"set at most one of policy_cbor_hex / policy_cbor_path".into(),
),
(Some(s), None) => {
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
hex_decode(&cleaned).map_err(|e| format!("decode policy_cbor_hex: {e}"))
}
(None, Some(p)) => {
let raw = std::fs::read_to_string(p)
.map_err(|e| format!("read policy_cbor_path '{p}': {e}"))?;
let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
if cleaned.is_empty() {
return Err(format!(
"policy_cbor_path '{p}' contained no hex characters"
));
}
hex_decode(&cleaned).map_err(|e| {
format!("decode policy_cbor_path '{p}' contents: {e}")
})
}
(None, None) => {
Err("must set exactly one of policy_cbor_hex / policy_cbor_path".into())
}
}
}
/// Parse a user-supplied script-kind string ("PlutusV1" / "PlutusV2"
/// / "PlutusV3" / "Native") into the pallas `ScriptKind` enum used
/// by the reference-script attachment helper. Case-insensitive,
/// trims whitespace; returns a clean error message on miss.
fn parse_script_kind(s: &str) -> Result<ScriptKind, String> {
match s.trim().to_ascii_lowercase().as_str() {
"plutusv1" | "v1" => Ok(ScriptKind::PlutusV1),
"plutusv2" | "v2" => Ok(ScriptKind::PlutusV2),
"plutusv3" | "v3" => Ok(ScriptKind::PlutusV3),
"native" => Ok(ScriptKind::Native),
other => Err(format!(
"invalid reference_script_kind '{other}'; expected one of: \
PlutusV1, PlutusV2, PlutusV3, Native"
)),
}
}
use rmcp::{
model::{ServerCapabilities, ServerInfo},
schemars, tool, ServerHandler,
};
use serde::Deserialize;
/// Schema-shape helper for `serde_json::Value` arg fields that
/// expect a JSON object (CIP-25 / CIP-68 metadata, multisig
/// PolicySpec dicts). schemars's default for `Option<Value>` /
/// `Value` emits a schema with no `type`, which Claude Code's MCP
/// client interprets as "string-encoded" — it then JSON-stringifies
/// the user's `{...}` before sending, and the server-side
/// validation `value.is_object()` returns false. Force
/// `type: object` so the client passes the value through as a
/// proper JSON object on the wire. (`additionalProperties: true`
/// keeps the schema permissive — these args really do accept
/// arbitrary keys.)
fn json_object_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::{InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec};
Schema::Object(SchemaObject {
instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))),
object: Some(Box::new(ObjectValidation {
additional_properties: Some(Box::new(Schema::Bool(true))),
..Default::default()
})),
..Default::default()
})
}
/// 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,
/// Cached Koios bearer token so on-demand `KoiosDiscoveryClient`
/// (and any future per-call client) inherits the same paid-tier
/// auth instead of falling back to free-tier and tripping daily
/// quotas. None = public tier. Sourced from env only — never from
/// disk; never logged.
koios_bearer: Option<String>,
}
impl WalletService {
pub fn new(
network: Network,
address: String,
koios_base: String,
koios_bearer: Option<String>,
payment_key: PaymentKey,
stake_key: StakeKey,
max_send_lovelace: u64,
data_dir: PathBuf,
) -> Self {
let bearer_ref = koios_bearer.as_deref();
Self {
inner: Arc::new(WalletInner {
network,
address,
chain: KoiosClient::with_timeout_and_bearer(
koios_base.clone(),
std::time::Duration::from_secs(10),
bearer_ref,
),
payment_key,
stake_key,
max_send_lovelace,
dao_store: DaoStore::new(&data_dir),
dao_reader: KoiosDaoReader::with_bearer(koios_base.clone(), bearer_ref),
koios_base,
koios_bearer,
}),
}
}
/// 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>,
/// Optional reference-script CBOR (hex). When set, the recipient
/// output carries the script as a reference-script (Babbage/Conway
/// era `--tx-out-reference-script-file` equivalent). Used to
/// deploy a Plutus validator/policy as a reusable on-chain
/// reference so downstream txs can witness it via `--tx-in-script-
/// file ref` instead of inline-witnessing the full CBOR. Pair with
/// `to_address` = wallet's own address so the wallet retains the
/// ability to retire the deployment later.
/// Requires `reference_script_kind` to also be set.
#[serde(default)]
pub reference_script_cbor_hex: Option<String>,
/// Path INSIDE THE ALDABRA CONTAINER to a file containing the
/// hex-encoded reference-script CBOR. Use INSTEAD of
/// `reference_script_cbor_hex` for scripts >~ 4KB to bypass the
/// MCP large-string transport bug (caught 2026-05-07: hex strings
/// > ~4500 chars get a 1-byte truncation + structural rearrangement
/// somewhere between Claude Code and aldabra's stdio reader).
/// File contents may include leading/trailing whitespace; only
/// hex chars are decoded. At most one of `reference_script_cbor_hex`
/// or `reference_script_path` may be set.
#[serde(default)]
pub reference_script_path: Option<String>,
/// Plutus version of the reference-script: "PlutusV1", "PlutusV2",
/// "PlutusV3", or "Native". Required when reference_script_cbor_hex
/// is set; ignored otherwise.
#[serde(default)]
pub reference_script_kind: 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>,
/// Optional reference-script CBOR (hex). See
/// [`SendArgs::reference_script_cbor_hex`].
#[serde(default)]
pub reference_script_cbor_hex: Option<String>,
/// Path INSIDE THE ALDABRA CONTAINER to a hex file. See
/// [`SendArgs::reference_script_path`] — same workaround for the
/// MCP large-string transport bug.
#[serde(default)]
pub reference_script_path: Option<String>,
/// "PlutusV1" | "PlutusV2" | "PlutusV3" | "Native". See
/// [`SendArgs::reference_script_kind`].
#[serde(default)]
pub reference_script_kind: 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 PlutusMintUnsignedArgs {
/// Plutus minting policy script CBOR (hex). 28-byte blake2b
/// hash with the version tag becomes the policy_id.
/// At most one of `policy_cbor_hex` or `policy_cbor_path` may
/// be set; exactly one must be set.
#[serde(default)]
pub policy_cbor_hex: Option<String>,
/// Path INSIDE THE ALDABRA CONTAINER to a file containing
/// hex-encoded Plutus policy CBOR. Use INSTEAD of
/// `policy_cbor_hex` for scripts >~ 4500 chars to bypass the
/// MCP large-string transport bug (caught 2026-05-07: hex strings
/// > ~4500 chars get a 1-byte truncation + structural rearrangement
/// somewhere between Claude Code and aldabra's stdio reader,
/// surfacing as "odd length" hex decode errors). File contents
/// may include leading/trailing whitespace; only hex chars are
/// decoded. At most one of `policy_cbor_hex` or `policy_cbor_path`
/// may be set; exactly one must be set.
#[serde(default)]
pub policy_cbor_path: Option<String>,
/// Plutus version: "v1", "v2", or "v3".
pub policy_version: String,
/// PlutusData CBOR redeemer (hex) for the mint redeemer entry.
pub redeemer_cbor_hex: String,
/// Assets to mint under this policy. Each entry needs an
/// `asset_name_hex` (hex of raw bytes, 0-64 chars) and a
/// `quantity` (i64). Quantity > 0 mints, < 0 burns. Burning
/// requires the wallet to already hold the asset.
pub mint_assets: Vec<PlutusMintAssetArg>,
/// Recipient address — typically a Plutus script address (e.g.
/// governor / stakes / proposal address from the Agora linker).
pub dest_address: String,
/// ADA on the recipient output. Must be ≥ min-utxo for the
/// shape (asset count + name length).
pub dest_lovelace: u64,
/// Non-mint native assets to forward from wallet inputs onto
/// the recipient output. Used e.g. on stake bootstrap to send
/// gov tokens (tTRP) into the stakes_addr alongside the freshly
/// minted StakeST.
#[serde(default)]
pub dest_extra_assets: Vec<McpAssetSpec>,
/// PlutusData CBOR (hex) for the recipient output's inline
/// datum. REQUIRED when sending to a script address — the
/// validator needs a datum to read on subsequent spends.
#[serde(default)]
pub dest_inline_datum_cbor_hex: Option<String>,
/// UTxOs that MUST appear as regular tx inputs. Each is
/// `txhash#index` referencing a UTxO at this wallet's address.
/// Use this to spend the UTxO a parameterized minting policy
/// is bound to (Agora's `gstOutRef` is the canonical case).
#[serde(default)]
pub required_input_refs: Vec<String>,
/// ExUnits budget for the mint redeemer. Defaults to the
/// generous DEFAULT_EX_UNITS if omitted. Tune for known
/// validators to keep the fee tight.
#[serde(default)]
pub ex_units_mem: Option<u64>,
#[serde(default)]
pub ex_units_steps: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct PlutusMintAssetArg {
/// Hex of raw asset name bytes (0-64 chars). Empty string for
/// policy-only / no-asset-name native assets.
pub asset_name_hex: String,
/// Positive = mint, negative = burn (caller must hold the
/// assets to burn).
pub quantity: i64,
}
#[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)]
#[schemars(schema_with = "json_object_schema")]
pub policy: Option<serde_json::Value>,
/// Optional CIP-25 v2 metadata.
#[serde(default)]
#[schemars(schema_with = "json_object_schema")]
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 DrepVoteCastArgs {
/// Conway governance action's tx hash (64-char hex).
pub gov_action_tx_hash: String,
/// The action_index inside that tx (typically 0).
pub gov_action_index: u32,
/// One of "yes", "no", "abstain". Case-insensitive.
pub vote: String,
/// Optional CIP-100 anchor URL (off-chain rationale for the vote).
#[serde(default)]
pub anchor_url: Option<String>,
/// 64-char hex blake2b-256 of the anchor content. Required when
/// anchor_url is set.
#[serde(default)]
pub anchor_data_hash_hex: Option<String>,
}
#[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.
#[schemars(schema_with = "json_object_schema")]
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)]
#[schemars(schema_with = "json_object_schema")]
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,
reference_script_cbor_hex,
reference_script_path,
reference_script_kind,
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 ref_script_bytes = resolve_ref_script_bytes(
reference_script_cbor_hex.as_deref(),
reference_script_path.as_deref(),
)?;
let ref_script = match (ref_script_bytes.as_ref(), reference_script_kind.as_deref()) {
(Some(bytes), Some(kind)) => Some(ReferenceScriptSpec {
kind: parse_script_kind(kind)?,
cbor: bytes.as_slice(),
}),
(Some(_), None) => {
return Err("reference_script_cbor_hex/path set without reference_script_kind".into())
}
(None, Some(_)) => {
return Err("reference_script_kind set without reference_script_cbor_hex/path".into())
}
(None, None) => None,
};
let cbor = build_signed_payment_extras(
&self.inner.payment_key,
self.inner.network,
&inputs,
&self.inner.address,
&to_address,
lovelace,
&asset_specs,
datum_bytes.as_deref(),
ref_script,
&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,
reference_script_cbor_hex,
reference_script_path,
reference_script_kind,
}: 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 ref_script_bytes = resolve_ref_script_bytes(
reference_script_cbor_hex.as_deref(),
reference_script_path.as_deref(),
)?;
let ref_script = match (ref_script_bytes.as_ref(), reference_script_kind.as_deref()) {
(Some(bytes), Some(kind)) => Some(ReferenceScriptSpec {
kind: parse_script_kind(kind)?,
cbor: bytes.as_slice(),
}),
(Some(_), None) => {
return Err("reference_script_cbor_hex/path set without reference_script_kind".into())
}
(None, Some(_)) => {
return Err("reference_script_kind set without reference_script_cbor_hex/path".into())
}
(None, None) => None,
};
let unsigned = build_unsigned_payment_extras(
self.inner.network,
&inputs,
&self.inner.address,
&to_address,
lovelace,
&asset_specs,
datum_bytes.as_deref(),
ref_script,
&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_drep_vote_cast",
description = "Conway: cast this wallet's DRep vote on a governance action. Args: gov_action_tx_hash (hex), gov_action_index (u32, typically 0), vote ('yes' | 'no' | 'abstain'), anchor_url (optional CIP-100 rationale URL), anchor_data_hash_hex (optional 64-char blake2b-256 hash; required if anchor_url set). The wallet's stake credential must already be registered as a DRep for the vote to count on chain. Returns submitted tx hash."
)]
async fn wallet_drep_vote_cast(
&self,
#[tool(aggr)] DrepVoteCastArgs {
gov_action_tx_hash,
gov_action_index,
vote,
anchor_url,
anchor_data_hash_hex,
}: DrepVoteCastArgs,
) -> Result<String, String> {
let vote_choice = match vote.to_ascii_lowercase().as_str() {
"yes" => aldabra_core::VoteChoice::Yes,
"no" => aldabra_core::VoteChoice::No,
"abstain" => aldabra_core::VoteChoice::Abstain,
other => return Err(format!("vote must be yes/no/abstain, got {other:?}")),
};
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 cbor = aldabra_core::governance::build_signed_drep_vote_cast(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
&gov_action_tx_hash,
gov_action_index,
vote_choice,
anchor_url.as_deref(),
anchor_data_hash_hex.as_deref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign vote cast: {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_plutus_mint_unsigned",
description = "Build (no sign) a Plutus-policy mint with custom output. Distinct from wallet_mint (native script only). Args: policy_cbor_hex OR policy_cbor_path (use path for >~4500-char scripts to bypass the MCP large-string transport bug) + policy_version (v1/v2/v3) + redeemer_cbor_hex + ex_units (mem+steps), mint_assets (array of {asset_name_hex, quantity}), dest_address + dest_lovelace + dest_extra_assets + dest_inline_datum_cbor_hex (req for script addrs), required_input_refs (array of 'txhash#index' UTxOs that MUST be spent — e.g. gstOutRef for parameterized policies). Returns JSON {cbor_hex, summary}. Pass through wallet_sign_partial → wallet_submit_signed_tx. Designed for Agora-style DAO bringup: governor bootstrap, stake bootstrap, proposal create."
)]
async fn wallet_plutus_mint_unsigned(
&self,
#[tool(aggr)] PlutusMintUnsignedArgs {
policy_cbor_hex,
policy_cbor_path,
policy_version,
redeemer_cbor_hex,
mint_assets,
dest_address,
dest_lovelace,
dest_extra_assets,
dest_inline_datum_cbor_hex,
required_input_refs,
ex_units_mem,
ex_units_steps,
}: PlutusMintUnsignedArgs,
) -> Result<String, String> {
if mint_assets.is_empty() {
return Err("mint_assets must contain at least one entry".into());
}
if dest_lovelace < 1_000_000 {
return Err(format!(
"dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO"
));
}
let policy_cbor = resolve_policy_cbor_bytes(
policy_cbor_hex.as_deref(),
policy_cbor_path.as_deref(),
)?;
let redeemer_cbor =
hex_decode(&redeemer_cbor_hex).map_err(|e| format!("decode redeemer: {e}"))?;
let policy_ver = match policy_version.trim().to_ascii_lowercase().as_str() {
"v1" | "plutusv1" => PlutusVersion::V1,
"v2" | "plutusv2" => PlutusVersion::V2,
"v3" | "plutusv3" => PlutusVersion::V3,
other => {
return Err(format!(
"invalid policy_version '{other}'; expected v1/v2/v3"
))
}
};
let datum_bytes = match dest_inline_datum_cbor_hex.as_deref() {
Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?),
None => None,
};
let core_mints: Vec<PlutusMintAsset> = mint_assets
.into_iter()
.map(|m| {
if m.quantity == 0 {
return Err("mint_asset quantity must be nonzero".to_string());
}
Ok(PlutusMintAsset {
asset_name_hex: m.asset_name_hex,
quantity: m.quantity,
})
})
.collect::<Result<_, _>>()?;
let core_extras: Vec<ExtraDestAsset> = dest_extra_assets
.into_iter()
.map(|a| ExtraDestAsset {
policy_id_hex: a.policy_id_hex,
asset_name_hex: a.asset_name_hex,
quantity: a.quantity,
})
.collect();
// Pull current UTxO set; resolve required_input_refs against it.
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 mut required: Vec<InputUtxo> = Vec::with_capacity(required_input_refs.len());
for r in &required_input_refs {
let (h, ix) = r
.split_once('#')
.ok_or_else(|| format!("required_input_ref '{r}' must be 'txhash#index'"))?;
let ix: u32 = ix.parse().map_err(|e| format!("required_input_ref idx: {e}"))?;
let found = inputs
.iter()
.find(|u| u.tx_hash_hex == h && u.output_index == ix)
.ok_or_else(|| {
format!(
"required_input {r} not found in this wallet's UTxOs — \
either fund it first, or pass an existing UTxO ref"
)
})?;
required.push(found.clone());
}
let ex_units = match (ex_units_mem, ex_units_steps) {
(Some(m), Some(s)) => PlutusExUnits { mem: m, steps: s },
(None, None) => DEFAULT_EX_UNITS,
_ => {
return Err(
"ex_units_mem and ex_units_steps must both be set or both omitted".into(),
)
}
};
let wallet_pkh = self.inner.payment_key.public_key_hash();
let signers = [wallet_pkh];
let core_args = CorePlutusMintArgs {
required_inputs: &required,
policy_cbor: &policy_cbor,
policy_version: policy_ver,
redeemer_cbor: &redeemer_cbor,
ex_units,
mint_assets: &core_mints,
dest_address_bech32: &dest_address,
dest_lovelace,
dest_extra_assets: &core_extras,
dest_inline_datum_cbor: datum_bytes.as_deref(),
additional_signers: &signers,
};
let unsigned = build_unsigned_plutus_mint(
self.inner.network,
&inputs,
&self.inner.address,
&core_args,
&ProtocolParams::default(),
)
.map_err(|e| format!("build unsigned plutus 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 + bearer as the wallet's chain backend.
let client = KoiosDiscoveryClient::with_bearer(
self.inner.koios_base.clone(),
self.inner.koios_bearer.as_deref(),
);
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,
starting_time_slot: posix_ms_to_slot(cfg.network, 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())?;
// 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 = slot_to_posix_ms(cfg.network, tip_slot)?;
// Compute the transition from current status + tx-validity vs window
// boundaries. The validator (Proposal/Scripts.hs PAdvanceProposal)
// checks `getTimingRelation period`, which evaluates to:
// - PWithin if `period_start <= lb && ub <= period_end`
// - PAfter if `period_end < lb` (strict)
// - script-error otherwise (including the boundary-straddling case)
// So our tx validity range [tip_ms, tip_ms + 1799s] must fully sit
// either inside the period OR strictly after period_end. Any
// straddle = waste of fees.
//
// AUDIT-2026-05-06 H-1/H-2/H-4 fixes: use STRICT > on PAfter
// boundary, require tx-upper to land inside the target period for
// PWithin, AND gate Locked→Finished on tx_lower > executing_end so
// we never hit the "missing GAT-mint" path.
use aldabra_dao::agora::proposal::ProposalStatus as PS;
const VALIDITY_RANGE_MS: i64 = aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS as i64 * 1000;
let tx_lower_ms = tip_ms;
let tx_upper_ms = tip_ms + VALIDITY_RANGE_MS;
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 executing_end = locking_end + tc.executing_time;
// The validity-range overrides we'll pass to the builder. None
// = builder uses [tip_slot, tip_slot + VALIDITY_RANGE_SLOTS] as
// before. Some(...) lets us clamp when the natural range would
// straddle a phase boundary — e.g. early Draft→VotingReady
// advance with the wide 1799-slot range ends 30min past
// starting_time, way past drafting_end on a 30-min DAO.
let mut valid_from_slot_override: Option<u64> = None;
let mut invalid_from_slot_override: Option<u64> = None;
let transition = match target.datum.status {
PS::Draft => {
if tx_lower_ms >= st && tx_upper_ms <= drafting_end {
// Already fully inside drafting period — happy path.
AdvanceTransition::DraftToVotingReady
} else if tx_lower_ms >= st && tx_lower_ms < drafting_end {
// Lower is in drafting but upper overflows. Clamp
// upper to drafting_end so the range fits — still
// satisfies validator's PWithin check, just narrower.
// Min 5-slot width so the chain has room to include.
let drafting_end_slot = posix_ms_to_slot(cfg.network, drafting_end)?;
if drafting_end_slot <= tip_slot + 5 {
return Err(format!(
"Draft→VotingReady early-advance: only {} slots of drafting period \
remaining (drafting_end_slot={drafting_end_slot}, tip_slot={tip_slot}); \
too narrow to include the tx. Wait for drafting period to fully expire \
then re-call to take the Draft→Finished path.",
drafting_end_slot.saturating_sub(tip_slot),
));
}
invalid_from_slot_override = Some(drafting_end_slot);
AdvanceTransition::DraftToVotingReady
} else if tx_lower_ms > drafting_end {
// Strictly after — failed-too-late path.
AdvanceTransition::DraftToFinished
} else {
return Err(format!(
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms cannot reach a clean \
in-drafting window [{st}, {drafting_end}] nor a strictly-past-drafting \
window — proposal starting_time may be in the future"
));
}
}
PS::VotingReady => {
if tx_lower_ms >= voting_end && tx_upper_ms <= locking_end {
AdvanceTransition::VotingReadyToLocked
} else if tx_lower_ms >= voting_end && tx_lower_ms < locking_end {
// Clamp upper to locking_end — same trick as Draft.
let locking_end_slot = posix_ms_to_slot(cfg.network, locking_end)?;
if locking_end_slot <= tip_slot + 5 {
return Err(format!(
"VotingReady→Locked: only {} slots of locking window remaining \
(locking_end_slot={locking_end_slot}, tip_slot={tip_slot}); \
too narrow to include the tx. Wait then re-call to take \
VotingReady→Finished.",
locking_end_slot.saturating_sub(tip_slot),
));
}
invalid_from_slot_override = Some(locking_end_slot);
AdvanceTransition::VotingReadyToLocked
} else if tx_lower_ms > locking_end {
AdvanceTransition::VotingReadyToFinished
} else if tx_lower_ms < voting_end {
return Err(format!(
"tx validity range starts at {tx_lower_ms} ms, before voting_end \
{voting_end} ms — voting window not yet closed; cannot advance to Locked"
));
} else {
return Err(format!(
"tx validity range [{tx_lower_ms}, {tx_upper_ms}] ms straddles locking \
period boundary [{voting_end}, {locking_end}]; wait ~{} ms for tx upper \
to clear",
locking_end.saturating_sub(tx_lower_ms)
));
}
}
PS::Locked => {
if tx_lower_ms > executing_end {
AdvanceTransition::LockedToFinished
} else {
return Err(format!(
"tip too early to advance Locked→Finished without GAT mint — executing \
period ends at {executing_end} ms, currently {tx_lower_ms} ms (~{} ms \
remaining). The GAT-mint Locked→Finished path (effected proposals) is \
Phase 4c-bis; for now the InfoOnly path requires the executing period \
to fully elapse first.",
executing_end.saturating_sub(tx_lower_ms)
));
}
}
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,
valid_from_slot_override,
invalid_from_slot_override,
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())?;
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 default_validity_upper_slot = tip_slot
+ aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS;
let tx_lower_ms = slot_to_posix_ms(cfg.network, tip_slot)?;
// AUDIT-2026-05-06 H-3 fix: validator (Proposal/Scripts.hs PVote
// ~L511) demands `pgetRelation == PWithin VotingPeriod`, where
// PWithin requires BOTH `voting_start <= lb` AND `ub <= voting_end`.
// The builder's existing preflight only verified the upper bound;
// a vote-too-early call (tip < voting_start) would burn fees on a
// "too early or invalid" script error. Catch lb-vs-voting_start
// here too.
//
// 2026-05-08 follow-up: when default validity_upper would
// overshoot voting_end (e.g. 30-min Sulkta-shape windows where
// the 1799-slot validity range starting from current tip lands
// past voting_end), clamp validity_upper_slot to voting_end_slot
// so the range fits inside the voting window. Same trick the
// proposal_advance Draft→VotingReady clamp uses.
//
// Read from prop_datum (target.datum was moved to prop_datum at L2636).
let voting_start_check = prop_datum.starting_time
+ prop_datum.timing_config.draft_time;
let voting_end_check = voting_start_check
+ prop_datum.timing_config.voting_time;
if tx_lower_ms < voting_start_check {
return Err(format!(
"tx lower bound {tx_lower_ms} ms is before voting window start {voting_start_check} ms \
(proposal #{proposal_id} draft period not over yet); wait ~{} ms",
voting_start_check.saturating_sub(tx_lower_ms)
));
}
let voting_end_slot = posix_ms_to_slot(cfg.network, voting_end_check)?;
let validity_upper_slot = if voting_end_slot < default_validity_upper_slot {
// Clamp to voting_end. Reject if remaining slots are too narrow
// to include the tx (≤ 5 slots is the same threshold the
// advance clamp uses).
if voting_end_slot <= tip_slot + 5 {
return Err(format!(
"voting window has only {} slots remaining (voting_end_slot={voting_end_slot}, \
tip_slot={tip_slot}) — too narrow to include the vote tx; voting period \
effectively closed for proposal #{proposal_id}",
voting_end_slot.saturating_sub(tip_slot),
));
}
voting_end_slot
} else {
default_validity_upper_slot
};
let validity_upper_ms = slot_to_posix_ms(cfg.network, 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_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_stake_retract_votes_unsigned",
description = "Build (but DO NOT submit) an unsigned retract-votes tx. Spends voter's stake (RetractVotes redeemer) + the proposal UTxO (UnlockStake redeemer). Removes the stake's locks for this proposal. Mode is auto-derived from the proposal's status: Finished → ALL locks for this proposal_id are dropped (including Created/Cosigned, finally letting the stake become destroyable); any other status → only Voted locks are dropped, AND only if past their cooldown (`createdAt + minStakeVotingTime ≤ tx_lower_ms`). When the proposal is VotingReady AND tx-validity sits inside the voting window, also subtracts stake.staked_amount from proposal.votes[voted_tag]. Pre-flights: voter is owner-or-delegatee, stake has at least one lock for this proposal_id, Voted-lock cooldown elapsed (when applicable), and rejects the VotingReady-no-Voted-lock case where the validator's `Votes changed` assertion would fail. Args: dao (optional — defaults to active), proposal_id (i64), fee_lovelace (~2_500_000 reasonable). Returns CBOR-hex of the unsigned tx body. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx."
)]
async fn dao_stake_retract_votes_unsigned(
&self,
#[tool(aggr)] DaoStakeRetractVotesArgs {
dao,
proposal_id,
fee_lovelace,
}: DaoStakeRetractVotesArgs,
) -> Result<String, String> {
let cfg = self
.inner
.dao_store
.resolve(dao.as_deref())
.map_err(|e| e.to_string())?;
let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| {
"DaoConfig.proposal_addr missing — register or discover_scripts first".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, 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 retract",
hex::encode(&voter_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())?;
// Chain tip slot + validity_lower_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 validity_lower_ms = slot_to_posix_ms(cfg.network, tip_slot)?;
// Wallet utxos.
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
};
// Reference UTxOs — same pattern as vote.
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_retract_votes(ProposalRetractVotesArgs {
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,
change_address: self.inner.address.clone(),
wallet_utxos,
tip_slot,
validity_lower_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,
"locks_removed": unsigned.locks_removed,
"vote_weight_retracted": unsigned.vote_weight_retracted,
"stake_still_locked_by_this_proposal": unsigned.stake_still_locked_by_this_proposal,
"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 DaoStakeRetractVotesArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// Proposal id whose locks should be retracted from this stake.
pub proposal_id: i64,
/// Estimated total fee in lovelace. ~2_500_000 reasonable for v1.
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.
///
/// Per-network Shelley genesis constants for slot↔POSIX-ms conversion.
///
/// Each tuple: (shelley_start_slot, shelley_start_posix_ms). Shelley+ era
/// uses 1-second slots on every network; the only network-specific values
/// are the start point of that 1-second-slot regime.
///
/// **Mainnet** — Shelley HF at slot 4_492_800 (epoch 208), 2020-07-29 21:44:51 UTC.
/// Pre-Shelley (Byron) slots had a different length; we don't support them.
///
/// **Preprod** — Byron-era genesis 2022-06-01, Shelley HF at slot 86_400
/// (= 86_400 × 20s Byron slots = 20 days), posix 2022-06-21 00:00 UTC.
///
/// **Preview** — single-era network; Shelley starts at slot 0,
/// posix 2022-10-25 00:00 UTC. (No Byron prologue.)
const MAINNET_SHELLEY_SLOT_ZERO: u64 = 4_492_800;
const MAINNET_SHELLEY_POSIX_MS_ZERO: i64 = 1_596_059_091_000;
const PREPROD_SHELLEY_SLOT_ZERO: u64 = 86_400;
const PREPROD_SHELLEY_POSIX_MS_ZERO: i64 = 1_655_769_600_000;
const PREVIEW_SHELLEY_SLOT_ZERO: u64 = 0;
const PREVIEW_SHELLEY_POSIX_MS_ZERO: i64 = 1_666_656_000_000;
fn shelley_constants(network: DaoNetwork) -> (u64, i64) {
match network {
DaoNetwork::Mainnet => (MAINNET_SHELLEY_SLOT_ZERO, MAINNET_SHELLEY_POSIX_MS_ZERO),
DaoNetwork::Preprod => (PREPROD_SHELLEY_SLOT_ZERO, PREPROD_SHELLEY_POSIX_MS_ZERO),
DaoNetwork::Preview => (PREVIEW_SHELLEY_SLOT_ZERO, PREVIEW_SHELLEY_POSIX_MS_ZERO),
}
}
/// Convert POSIX milliseconds to an absolute slot for the given network.
fn posix_ms_to_slot(network: DaoNetwork, posix_ms: i64) -> Result<u64, String> {
let (slot_zero, posix_ms_zero) = shelley_constants(network);
if posix_ms < posix_ms_zero {
return Err(format!(
"posix_ms {posix_ms} is pre-Shelley on {network:?} (< {posix_ms_zero})"
));
}
let delta_ms = posix_ms - posix_ms_zero;
let delta_slots = (delta_ms / 1000) as u64;
Ok(slot_zero + delta_slots)
}
/// Convert an absolute slot to POSIX milliseconds for the given network.
///
/// Caveat: only valid for slots ≥ that network's Shelley-HF slot. Returns
/// `Err` for pre-Shelley (Byron) slots — they had a different length and
/// we never need them for DAO operations.
fn slot_to_posix_ms(network: DaoNetwork, slot: u64) -> Result<i64, String> {
let (slot_zero, posix_ms_zero) = shelley_constants(network);
if slot < slot_zero {
return Err(format!(
"slot {slot} is pre-Shelley on {network:?} (< {slot_zero}); \
slot↔ms conversion only supported for Shelley+ era"
));
}
let delta_slots = slot - slot_zero;
let delta_ms = (delta_slots as i64).checked_mul(1000).ok_or_else(|| {
format!("slot delta {delta_slots} * 1000 overflows i64")
})?;
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, wallet_drep_vote_cast for casting Yes/No/Abstain votes on governance actions). 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_retract_votes_unsigned (drop a stake's locks for a given proposal — Finished proposals drop ALL locks, others drop only past-cooldown Voted locks; pre-condition for stake destroy on a stake that voted), 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}"
);
}
}