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