phase 4.5, 4.6, 3.6 close-out: stake delegation + multisig mint primitive
stake key + reward address (4.5):
- StakeKey::stake_address(network) — bech32 (`stake1...` mainnet,
`stake_test1...` testnet) via pallas_addresses::StakeAddress::new
(added to the fork in the same commit since the upstream tuple
struct had no public constructor).
- StakeKey::xprv() — crate-internal accessor for signing.
- WalletInner now holds the stake_key alongside the payment_key.
- mcp tool wallet.stake.address surfaces the bech32.
stake delegation (4.6):
- new aldabra-core::stake module:
- parse_pool_id(bech32) → Hash<28>
- build_signed_stake_delegation(payment, stake, network, utxos,
change_addr, pool_bech32, register_first, params) → signed cbor.
- if register_first: prepends a StakeRegistration cert (consumes
a 2 ADA deposit from inputs). otherwise just delegates.
- signs with both payment_key (body witness) and stake_key (cert
witness). reuses sign::add_witness for both — same body-hash
ed25519 signing path regardless of CIP-1852 chain index.
- mcp tool wallet.stake.delegate: pool_id, register_first (defaults
true). signs + submits.
3.6 close-out — wallet.mint.unsigned mcp tool:
- exposes the existing build_unsigned_mint with caller-supplied
PolicySpec (json), so multi-sig / treasury flows can build through
this wallet without it auto-signing. round-trip with
wallet.sign_partial chain → wallet.submit_signed_tx.
depends on Sulkta-Coop/pallas@feat-aux-data which gained two more
patches in the same branch:
- StakeAddress::new public constructor.
- StagingTransaction::add_certificate / clear_certificates +
Conway::build_conway_raw decode-and-plumb for certs (filling in the
`certificates: None, // TODO` upstream).
mcp tools: 12 → 15 (wallet.stake.address, wallet.stake.delegate,
wallet.mint.unsigned).
79 → 84 unit tests. new coverage: stake address bech32 round-trip,
pool_id bech32 parse + reject-wrong-hrp, delegation tx with + without
registration (asserts cert count, witness count, cert variants).
fork tests grew: certificates_plumb_through_to_tx_body and
no_certificates_means_none.
This commit is contained in:
parent
640af23598
commit
49986d2864
7 changed files with 669 additions and 9 deletions
|
|
@ -81,6 +81,7 @@ async fn run() -> Result<()> {
|
|||
)?;
|
||||
let payment_key =
|
||||
aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
||||
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
|
||||
tracing::info!(%address, "derived base address");
|
||||
|
||||
if bootstrap_only {
|
||||
|
|
@ -95,6 +96,7 @@ async fn run() -> Result<()> {
|
|||
address,
|
||||
cfg.koios_base,
|
||||
payment_key,
|
||||
stake_key,
|
||||
cfg.max_send_lovelace,
|
||||
);
|
||||
let server = service
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ use std::sync::Arc;
|
|||
use aldabra_chain::{ChainBackend, KoiosClient};
|
||||
use aldabra_core::{
|
||||
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
|
||||
build_signed_payment_with_assets, build_unsigned_payment_with_assets, hex_decode, AssetSpec,
|
||||
InputUtxo, Network, PaymentKey, PolicySpec, ProtocolParams,
|
||||
build_signed_payment_with_assets, build_signed_stake_delegation, build_unsigned_mint,
|
||||
build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, Network, PaymentKey,
|
||||
PolicySpec, ProtocolParams, StakeKey,
|
||||
};
|
||||
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -65,6 +66,7 @@ struct WalletInner {
|
|||
address: String,
|
||||
chain: KoiosClient,
|
||||
payment_key: PaymentKey,
|
||||
stake_key: StakeKey,
|
||||
max_send_lovelace: u64,
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +76,7 @@ impl WalletService {
|
|||
address: String,
|
||||
koios_base: String,
|
||||
payment_key: PaymentKey,
|
||||
stake_key: StakeKey,
|
||||
max_send_lovelace: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
|
@ -82,6 +85,7 @@ impl WalletService {
|
|||
address,
|
||||
chain: KoiosClient::new(koios_base),
|
||||
payment_key,
|
||||
stake_key,
|
||||
max_send_lovelace,
|
||||
}),
|
||||
}
|
||||
|
|
@ -139,6 +143,46 @@ pub struct PolicyCreateArgs {
|
|||
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 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 SignPartialArgs {
|
||||
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
|
||||
|
|
@ -220,6 +264,17 @@ impl WalletService {
|
|||
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"
|
||||
|
|
@ -600,6 +655,144 @@ impl WalletService {
|
|||
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.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.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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue