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
f376481a8f
commit
0ba95c1709
7 changed files with 669 additions and 9 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<String, crate::WalletError> {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
408
crates/aldabra-core/src/stake.rs
Normal file
408
crates/aldabra-core/src/stake.rs
Normal file
|
|
@ -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<Hash<28>, 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<u8> = Vec::<u8>::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, WalletError> {
|
||||
pallas_addresses::Address::from_bech32(bech32)
|
||||
.map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, 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<Vec<u8>, 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<u8>> = 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<InputUtxo> = available_utxos.to_vec();
|
||||
sorted.sort_by(|a, b| b.lovelace.cmp(&a.lovelace));
|
||||
let mut acc: u64 = 0;
|
||||
let mut selected: Vec<InputUtxo> = 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<String, u64> = 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<StagingTransaction, WalletError> {
|
||||
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(_, _)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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. ADAMaps treasury 2-of-2): 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