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).
3922 lines
158 KiB
Rust
3922 lines
158 KiB
Rust
//! 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 (0–64 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,
|
||
¶ms,
|
||
)
|
||
.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}"
|
||
);
|
||
}
|
||
}
|