diff --git a/Cargo.lock b/Cargo.lock index 5655395..6e4445d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ name = "aldabra-core" version = "0.0.1" dependencies = [ + "bech32", "bip39", "cryptoxide", "ed25519-bip32", @@ -1251,7 +1252,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "pallas-addresses" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "base58", "bech32", @@ -1266,7 +1267,7 @@ dependencies = [ [[package]] name = "pallas-codec" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "hex", "minicbor", @@ -1277,7 +1278,7 @@ dependencies = [ [[package]] name = "pallas-crypto" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "cryptoxide", "hex", @@ -1291,7 +1292,7 @@ dependencies = [ [[package]] name = "pallas-primitives" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "base58", "bech32", @@ -1306,7 +1307,7 @@ dependencies = [ [[package]] name = "pallas-traverse" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "hex", "itertools", @@ -1322,7 +1323,7 @@ dependencies = [ [[package]] name = "pallas-txbuilder" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "hex", "pallas-addresses", @@ -1339,7 +1340,7 @@ dependencies = [ [[package]] name = "pallas-wallet" version = "0.32.1" -source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#57b36d3a7c840569d4d86f6d805a42aa4cf85347" +source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72" dependencies = [ "bech32", "bip39", diff --git a/crates/aldabra-core/Cargo.toml b/crates/aldabra-core/Cargo.toml index 422025a..056ce8f 100644 --- a/crates/aldabra-core/Cargo.toml +++ b/crates/aldabra-core/Cargo.toml @@ -33,6 +33,7 @@ pallas-txbuilder = { workspace = true } pallas-wallet = { workspace = true } pallas-traverse = { workspace = true } bip39 = { workspace = true } +bech32 = "0.9" ed25519-bip32 = { workspace = true } cryptoxide = { workspace = true } zeroize = { workspace = true } diff --git a/crates/aldabra-core/src/derive.rs b/crates/aldabra-core/src/derive.rs index b20e135..4e74d5b 100644 --- a/crates/aldabra-core/src/derive.rs +++ b/crates/aldabra-core/src/derive.rs @@ -60,6 +60,14 @@ pub struct PaymentKey { } impl PaymentKey { + /// Crate-internal — wrap an existing XPrv as a PaymentKey. Used + /// by the stake module to reuse the witness-append path with a + /// stake XPrv (the body-hash ed25519 signing logic is the same + /// regardless of CIP-1852 chain index). + pub(crate) fn from_xprv(xprv: XPrv) -> Self { + Self { xprv } + } + /// Blake2b-224 hash of the 32-byte raw public key — the canonical /// payment-key hash that goes into a Shelley base address's /// payment part. @@ -87,6 +95,26 @@ impl StakeKey { pub fn public_key_hash(&self) -> Hash<28> { Hasher::<224>::hash(self.xprv.public().public_key_bytes()) } + + /// Reward / stake address (`stake1...` or `stake_test1...`) + /// bech32-encoded. This is the address you point at a stake pool + /// when delegating. + pub fn stake_address( + &self, + network: crate::Network, + ) -> Result { + use pallas_addresses::{StakeAddress, StakePayload}; + let payload = StakePayload::Stake(self.public_key_hash()); + let addr = StakeAddress::new(network.to_pallas(), payload); + addr.to_bech32() + .map_err(|e| crate::WalletError::Address(e.to_string())) + } + + /// Borrow the underlying XPrv for signing — used by the cert / + /// delegation flow. + pub(crate) fn xprv(&self) -> &ed25519_bip32::XPrv { + &self.xprv + } } /// Derive a payment key at `m/1852'/1815'/account'/0/index`. @@ -172,4 +200,28 @@ mod tests { // different key hashes. assert_ne!(payment.public_key_hash(), stake.public_key_hash()); } + + #[test] + fn stake_address_round_trips_through_pallas() { + let root = root_from_canonical(); + let stake = derive_stake_key(&root, 0); + let mainnet_addr = stake.stake_address(crate::Network::Mainnet).unwrap(); + assert!( + mainnet_addr.starts_with("stake1"), + "expected stake1... got {mainnet_addr}" + ); + let testnet_addr = stake.stake_address(crate::Network::Preprod).unwrap(); + assert!( + testnet_addr.starts_with("stake_test1"), + "expected stake_test1... got {testnet_addr}" + ); + // Round-trip via pallas-addresses. + let parsed = pallas_addresses::Address::from_bech32(&mainnet_addr).unwrap(); + match parsed { + pallas_addresses::Address::Stake(s) => { + assert_eq!(s.network(), pallas_addresses::Network::Mainnet); + } + other => panic!("expected Stake address, got {other:?}"), + } + } } diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 4c04699..c633acf 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -40,17 +40,20 @@ pub mod derive; pub mod metadata; pub mod mint; pub mod sign; +pub mod stake; pub mod tx; pub use cip68::{ build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name, }; pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey}; +// Stake address derivation lives directly on StakeKey — exported above. pub use metadata::{build_cip25_aux_data, CIP25_LABEL}; pub use mint::{ build_signed_cip68_nft_mint, build_signed_mint, build_signed_mint_with_metadata, build_unsigned_mint, PolicySpec, }; pub use sign::add_witness; +pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE}; pub use tx::{ build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary, diff --git a/crates/aldabra-core/src/stake.rs b/crates/aldabra-core/src/stake.rs new file mode 100644 index 0000000..b868068 --- /dev/null +++ b/crates/aldabra-core/src/stake.rs @@ -0,0 +1,408 @@ +//! Stake registration + delegation flows. +//! +//! Cardano's reward system requires: +//! 1. The wallet's stake credential to be **registered** (one-time +//! 2 ADA deposit, refunded on deregistration). +//! 2. A **delegation** certificate pointing the stake credential at a +//! pool's keyhash. +//! +//! Both are `Certificate` entries in the tx body. Witnesses come from +//! both the payment key (for the body) and the stake key (for the +//! certificate-bound credential). + +use bech32::FromBase32; +use pallas_codec::minicbor; +use pallas_crypto::hash::Hash; +use pallas_primitives::conway::{Certificate, StakeCredential}; +use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; + +use crate::sign::add_witness; +use crate::tx::InputUtxo; +use crate::{Network, PaymentKey, ProtocolParams, StakeKey, WalletError}; + +/// Cardano stake-registration deposit (2 ADA, fixed by protocol since +/// Shelley). Refunded on deregistration. +pub const STAKE_KEY_DEPOSIT_LOVELACE: u64 = 2_000_000; + +const WITNESS_OVERHEAD_BYTES: u64 = 128; +/// Two witnesses (payment + stake) instead of just one — used for fee +/// estimation when both keys sign. +const TWO_WITNESS_OVERHEAD_BYTES: u64 = 256; + +/// Decode a `pool1...` bech32 pool ID into a 28-byte Hash. +pub fn parse_pool_id(bech32_str: &str) -> Result, WalletError> { + let (hrp, data, _variant) = bech32::decode(bech32_str) + .map_err(|e| WalletError::Address(format!("bad pool bech32: {e}")))?; + if hrp != "pool" { + return Err(WalletError::Address(format!( + "expected hrp 'pool', got '{hrp}'" + ))); + } + let bytes: Vec = Vec::::from_base32(&data) + .map_err(|e| WalletError::Address(format!("bad pool base32: {e}")))?; + if bytes.len() != 28 { + return Err(WalletError::Address(format!( + "pool id must be 28 bytes, got {}", + bytes.len() + ))); + } + let mut out = [0u8; 28]; + out.copy_from_slice(&bytes); + Ok(Hash::<28>::new(out)) +} + +fn parse_address(bech32: &str) -> Result { + pallas_addresses::Address::from_bech32(bech32) + .map_err(|e| WalletError::Address(e.to_string())) +} + +fn parse_tx_hash(hex_str: &str) -> Result, WalletError> { + if hex_str.len() != 64 { + return Err(WalletError::Derivation(format!( + "expected 64-char hex tx_hash, got {}", + hex_str.len() + ))); + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) + .map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?; + } + Ok(Hash::<32>::new(out)) +} + +fn network_id_for(network: Network) -> u8 { + match network { + Network::Mainnet => 1, + Network::Preview | Network::Preprod => 0, + } +} + +/// Build + sign a stake delegation transaction. If `register_first` is +/// true, prepends a `StakeRegistration` certificate (one-time, costs +/// 2 ADA deposit). Otherwise just delegates. +/// +/// The tx is signed by both the payment key (body witness) and the +/// stake key (cert witness). Returned CBOR is ready for submission. +pub fn build_signed_stake_delegation( + payment_key: &PaymentKey, + stake_key: &StakeKey, + network: Network, + available_utxos: &[InputUtxo], + change_address_bech32: &str, + pool_id_bech32: &str, + register_first: bool, + params: &ProtocolParams, +) -> Result, WalletError> { + let pool_hash = parse_pool_id(pool_id_bech32)?; + let stake_pkh = stake_key.public_key_hash(); + let credential = StakeCredential::AddrKeyhash(stake_pkh); + + let mut cert_bytes_list: Vec> = Vec::new(); + if register_first { + let reg = Certificate::StakeRegistration(credential.clone()); + cert_bytes_list.push( + minicbor::to_vec(®) + .map_err(|e| WalletError::Derivation(format!("encode reg cert: {e}")))?, + ); + } + let deleg = Certificate::StakeDelegation(credential, pool_hash); + cert_bytes_list.push( + minicbor::to_vec(&deleg) + .map_err(|e| WalletError::Derivation(format!("encode deleg cert: {e}")))?, + ); + + let change_addr = parse_address(change_address_bech32)?; + let network_id = network_id_for(network); + + let deposit = if register_first { + STAKE_KEY_DEPOSIT_LOVELACE + } else { + 0 + }; + + // Lovelace need: deposit + fee + min_change. + let fee_pass1: u64 = 500_000; + let need = deposit + .checked_add(fee_pass1) + .and_then(|x| x.checked_add(params.min_utxo_lovelace)) + .ok_or_else(|| WalletError::Derivation("amount overflow".into()))?; + + let mut sorted: Vec = available_utxos.to_vec(); + sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace)); + let mut acc: u64 = 0; + let mut selected: Vec = Vec::new(); + for u in sorted { + acc = acc.saturating_add(u.lovelace); + selected.push(u); + if acc >= need { + break; + } + } + if acc < need { + return Err(WalletError::Derivation(format!( + "insufficient funds for stake delegation: need {need} lovelace (deposit+fee+min_change), have {acc}" + ))); + } + let total_in: u64 = selected.iter().map(|u| u.lovelace).sum(); + + // Aggregate input assets — we preserve them on change. + let mut input_assets: std::collections::BTreeMap = Default::default(); + for u in &selected { + for (k, v) in &u.assets { + let entry = input_assets.entry(k.clone()).or_insert(0); + *entry = entry.saturating_add(*v); + } + } + + let build_with_fee = |fee: u64, + change_lovelace: u64| + -> Result { + let mut staging = StagingTransaction::new(); + for u in &selected { + let h = parse_tx_hash(&u.tx_hash_hex)?; + staging = staging.input(Input::new(h, u.output_index as u64)); + } + let mut change_out = Output::new(change_addr.clone(), change_lovelace); + for (k, q) in &input_assets { + if *q == 0 { + continue; + } + if k.len() < 56 { + return Err(WalletError::Derivation( + "asset key shorter than 56 chars".into(), + )); + } + let pol_hex = &k[..56]; + let name_hex = &k[56..]; + let mut policy_bytes = [0u8; 28]; + for i in 0..28 { + policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16) + .map_err(|_| WalletError::Derivation("invalid policy hex in asset key".into()))?; + } + let policy = Hash::<28>::new(policy_bytes); + let mut name_bytes = Vec::with_capacity(name_hex.len() / 2); + for i in (0..name_hex.len()).step_by(2) { + name_bytes.push( + u8::from_str_radix(&name_hex[i..i + 2], 16) + .map_err(|_| WalletError::Derivation("invalid name hex".into()))?, + ); + } + change_out = change_out + .add_asset(policy, name_bytes, *q) + .map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?; + } + staging = staging.output(change_out); + for cb in &cert_bytes_list { + staging = staging.add_certificate(cb.clone()); + } + staging = staging.fee(fee).network_id(network_id); + Ok(staging) + }; + + // Pass 1 — measure unsigned size. + let change_pass1 = total_in + .checked_sub(deposit + fee_pass1) + .ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?; + let staging1 = build_with_fee(fee_pass1, change_pass1)?; + let unsigned = staging1 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))? + .tx_bytes + .0; + // Two witnesses for delegation: payment + stake. Fee estimate + // accounts for both. + let witness_overhead = if register_first { + TWO_WITNESS_OVERHEAD_BYTES + } else { + TWO_WITNESS_OVERHEAD_BYTES + }; + let _ = WITNESS_OVERHEAD_BYTES; // silence unused if both branches use the two-witness constant + let est_signed = (unsigned.len() as u64) + witness_overhead; + let real_fee = params.min_fee_for_size(est_signed); + + let token_change = !input_assets.is_empty(); + let final_change = total_in + .checked_sub(deposit + real_fee) + .ok_or_else(|| WalletError::Derivation(format!( + "insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}" + )))?; + if final_change < params.min_utxo_lovelace && token_change { + return Err(WalletError::Derivation(format!( + "insufficient ADA for token-bearing change: change={final_change}, min={}", + params.min_utxo_lovelace + ))); + } + // No "merge change into fee" path here — the change output is + // necessary to balance the tx. Users with sub-min ADA after + // deposit+fee should top up first. + if final_change < params.min_utxo_lovelace { + return Err(WalletError::Derivation(format!( + "change after deposit+fee ({final_change}) below min utxo ({}). top up the wallet.", + params.min_utxo_lovelace + ))); + } + + let staging2 = build_with_fee(real_fee, final_change)?; + let built = staging2 + .build_conway_raw() + .map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?; + + // Sign with payment key (body witness). + let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?; + // Sign with stake key as well — the cert needs the stake credential's + // signature. add_witness reuses the same body-hash + ed25519 + // signing path; just feeds in a different XPrv. + let stake_payment_proxy = stake_key_as_payment_proxy(stake_key); + let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?; + Ok(fully_signed) +} + +/// Wrap a `StakeKey`'s XPrv into a `PaymentKey` so it can be passed +/// to `add_witness`. Both types are thin newtypes over `XPrv`; the +/// body-hash signing logic is identical regardless of which key +/// "role" the wallet considers the XPrv. Crate-internal helper — +/// callers use `build_signed_stake_delegation` end-to-end. +fn stake_key_as_payment_proxy(stake_key: &StakeKey) -> PaymentKey { + crate::derive::PaymentKey::from_xprv(stake_key.xprv().clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Mnemonic, ProtocolParams}; + + const ABANDON_ART: &str = concat!( + "abandon abandon abandon abandon abandon abandon ", + "abandon abandon abandon abandon abandon abandon ", + "abandon abandon abandon abandon abandon abandon ", + "abandon abandon abandon abandon abandon art", + ); + + /// Synthesize a valid `pool1...` bech32 from a constant 28-byte + /// hash. Round-trips by construction; we never submit. + fn known_pool_bech32() -> String { + let bytes = [0xaau8; 28]; + let data = bech32::ToBase32::to_base32(&bytes); + bech32::encode("pool", data, bech32::Variant::Bech32).unwrap() + } + + fn payment_from_canonical() -> PaymentKey { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive::derive_payment_key(&root, 0, 0) + } + + fn stake_from_canonical() -> StakeKey { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive::derive_stake_key(&root, 0) + } + + fn change_address(network: Network) -> String { + let root = Mnemonic::from_phrase(ABANDON_ART) + .unwrap() + .into_root_key() + .unwrap(); + crate::derive_base_address(&root, network, 0, 0).unwrap() + } + + #[test] + fn parse_pool_id_decodes_canonical() { + let h = parse_pool_id(&known_pool_bech32()).expect("pool id parses"); + assert_eq!(h.as_ref().len(), 28); + } + + #[test] + fn parse_pool_id_rejects_wrong_hrp() { + // addr_test... is wrong hrp for a pool id. + let r = parse_pool_id( + "addr_test1qqqt0pru382hy9vjlsxv3ye02z50sfvt8xunscg5pgden77z73dpdfng2ctw2ekqplqgrljelz7h4dneac27nn3qx3rqqpavzj", + ); + assert!(r.is_err()); + } + + #[test] + fn build_signed_stake_delegation_with_registration() { + use pallas_primitives::Fragment; + let payment = payment_from_canonical(); + let stake = stake_from_canonical(); + let change = change_address(Network::Preprod); + let utxos = vec![InputUtxo { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 100_000_000, + assets: Default::default(), + }]; + let cbor = build_signed_stake_delegation( + &payment, + &stake, + Network::Preprod, + &utxos, + &change, + &known_pool_bech32(), + true, + &ProtocolParams::default(), + ) + .expect("delegation tx builds + signs"); + assert!(cbor.len() > 200); + + let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor) + .expect("decode signed delegation cbor"); + + // Two certs: registration + delegation. + let certs = tx + .transaction_body + .certificates + .expect("certificates set") + .to_vec(); + assert_eq!(certs.len(), 2); + assert!(matches!(certs[0], Certificate::StakeRegistration(_))); + assert!(matches!(certs[1], Certificate::StakeDelegation(_, _))); + + // Two witnesses: payment + stake. + let witnesses = tx + .transaction_witness_set + .vkeywitness + .map(|w| w.to_vec().len()) + .unwrap_or(0); + assert_eq!(witnesses, 2); + } + + #[test] + fn build_signed_stake_delegation_without_registration() { + use pallas_primitives::Fragment; + let payment = payment_from_canonical(); + let stake = stake_from_canonical(); + let change = change_address(Network::Preprod); + let utxos = vec![InputUtxo { + tx_hash_hex: "deadbeef".repeat(8), + output_index: 0, + lovelace: 100_000_000, + assets: Default::default(), + }]; + let cbor = build_signed_stake_delegation( + &payment, + &stake, + Network::Preprod, + &utxos, + &change, + &known_pool_bech32(), + false, + &ProtocolParams::default(), + ) + .expect("delegation-only tx builds"); + let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor).unwrap(); + let certs = tx + .transaction_body + .certificates + .expect("certificates set") + .to_vec(); + assert_eq!(certs.len(), 1, "only delegation cert when not registering"); + assert!(matches!(certs[0], Certificate::StakeDelegation(_, _))); + } +} diff --git a/crates/aldabra-mcp/src/main.rs b/crates/aldabra-mcp/src/main.rs index 09c00b3..2ebdcfc 100644 --- a/crates/aldabra-mcp/src/main.rs +++ b/crates/aldabra-mcp/src/main.rs @@ -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 diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index a5ada18..53e5a90 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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, } +#[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, + /// Optional CIP-25 v2 metadata. + #[serde(default)] + pub metadata: Option, + /// 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, +} + +#[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 { + 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 { + 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 = 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 { + 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 = 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. ADAMaps treasury 2-of-2): each party calls this in turn, then any party submits via wallet.submit_signed_tx."