Skip to main content

seedelf_core/
staking.rs

1//! Staking and voting delegation for a Cardano account: certificates and
2//! reward withdrawals, riding in an account transaction.
3//!
4//! Pallas's builder can stage neither (`pallas-txbuilder` 0.35 writes `None`
5//! for both, and 1.4 is the same), so an account transaction is staged and
6//! built as usual, then [`Staking::patch`] decodes the body, sets them, and
7//! encodes it again. That changes the body's hash: the patched transaction
8//! comes back with its new hash, so it's always patched first and signed
9//! after, and `BuiltTransaction::sign` signs the right hash.
10//!
11//! The ledger's rules, which the builders here keep:
12//!
13//! - A key is registered with a deposit (`key_deposit`), and unregistering
14//!   returns exactly the deposit paid. Registering and delegating go in one
15//!   certificate (Conway's `StakeRegDeleg` and `VoteRegDeleg`).
16//! - A withdrawal takes the whole reward balance: the ledger refuses anything
17//!   else. Since Conway's second phase it also refuses one from a key whose
18//!   vote isn't delegated (to a DRep, or always abstain or no confidence).
19//! - Unregistering needs an empty reward balance, so it withdraws it too.
20//! - Every stake certificate here, and a withdrawal, needs the stake key's
21//!   signature.
22//!
23//! And for the account's own DRep (CIP-105's key `3/0`), the votes it casts
24//! are patched in the same way (the body's `voting_procedures`):
25//!
26//! - Registering locks `drep_deposit`, and retiring returns exactly what was
27//!   paid. Each DRep certificate, and each vote, needs the DRep key's
28//!   signature, so a transaction can need two keys besides the payment keys.
29//! - Certificates are applied in order, so a registration can be followed by
30//!   the stake key's vote delegation to it in the same transaction.
31//! - Retiring while the account delegates its own vote to itself moves that
32//!   vote to always abstain in the same transaction: the ledger may drop a
33//!   delegation to a retired DRep, which would lock the rewards.
34
35use std::fmt;
36
37use anyhow::{Context, Result, anyhow, bail};
38use bech32::{FromBase32, ToBase32, Variant};
39use pallas_addresses::{Address, StakePayload};
40use pallas_crypto::hash::Hash;
41use pallas_primitives::conway::{
42    self, Anchor, Certificate, DRep, GovActionId, Vote, Voter, VotingProcedure,
43};
44use pallas_primitives::{Fragment, NonEmptyKeyValuePairs, NonEmptySet, Nullable, StakeCredential};
45use pallas_txbuilder::BuiltTransaction;
46use seedelf_koios::koios::ProtocolParameters;
47
48use crate::build::tx_id;
49
50/// Koios's names for the two pinned vote delegations (`account_info`'s
51/// `delegated_drep`), which the wallet uses too.
52pub const ALWAYS_ABSTAIN: &str = "drep_always_abstain";
53pub const ALWAYS_NO_CONFIDENCE: &str = "drep_always_no_confidence";
54
55/// CIP-129's header byte of a DRep ID: a key hash, or a script hash.
56const DREP_KEY_HEADER: u8 = 0x22;
57const DREP_SCRIPT_HEADER: u8 = 0x23;
58
59/// The account's stake key, as certificates and withdrawals name it.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct StakeKey {
62    credential: StakeCredential,
63    /// The reward address's bytes: what a withdrawal is keyed by.
64    reward_account: Vec<u8>,
65}
66
67impl StakeKey {
68    /// The key of a reward (stake) address. A script's reward address isn't
69    /// an account's, so it's refused.
70    pub fn new(stake_address: &Address) -> Result<Self> {
71        let Address::Stake(stake) = stake_address else {
72            bail!("A stake key is named by its reward (stake) address");
73        };
74        let StakePayload::Stake(hash) = stake.payload() else {
75            bail!("That reward address belongs to a script, not an account's stake key");
76        };
77        Ok(StakeKey {
78            credential: StakeCredential::AddrKeyhash(*hash),
79            reward_account: stake_address.to_vec(),
80        })
81    }
82}
83
84/// The account's DRep key (CIP-105's `3/0`), as certificates and votes name
85/// it.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct DrepKey {
88    hash: Hash<28>,
89}
90
91impl DrepKey {
92    /// The DRep whose key hashes to `hash`.
93    pub fn new(hash: Hash<28>) -> Self {
94        DrepKey { hash }
95    }
96
97    pub fn hash(&self) -> Hash<28> {
98        self.hash
99    }
100
101    /// As a DRep certificate's credential.
102    fn credential(&self) -> StakeCredential {
103        StakeCredential::AddrKeyhash(self.hash)
104    }
105
106    /// As a vote delegation names it.
107    pub fn drep(&self) -> DRep {
108        DRep::Key(self.hash)
109    }
110
111    /// Its ID, CIP-129, as Koios names it.
112    pub fn id(&self) -> String {
113        drep_id(&self.drep())
114    }
115}
116
117/// Where the account's DRep stands, as Koios's `drep_info` says (no row: it
118/// was never registered).
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct DrepState {
121    pub registered: bool,
122    /// The deposit paid when it registered: what retiring returns.
123    pub deposit: u64,
124    /// Whether the account's own stake key delegates its vote to it.
125    pub own_vote: bool,
126}
127
128/// Something to do as the account's DRep.
129#[derive(Debug, Clone, PartialEq)]
130pub enum DrepAction {
131    /// Register it, with its profile's anchor when there is one, and
132    /// (`delegate`) delegate the account's own vote to it.
133    Register {
134        anchor: Option<Anchor>,
135        delegate: bool,
136    },
137    /// Change its profile's anchor, or take it away. It also keeps the DRep
138    /// active.
139    Update { anchor: Option<Anchor> },
140    /// Retire it and get its deposit back.
141    Retire,
142    /// Vote on governance actions: each its vote, with no rationale.
143    Vote(Vec<(GovActionId, Vote)>),
144}
145
146/// Where the stake key stands, as Koios's `account_info` says.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub struct StakeState {
149    pub registered: bool,
150    /// The deposit paid when it was registered: what unregistering returns.
151    pub deposit: u64,
152    /// The reward balance: a withdrawal must take all of it.
153    pub rewards: u64,
154    /// Whether its vote is delegated, which Conway needs for a withdrawal.
155    pub votes: bool,
156}
157
158/// Something to do with the stake key.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum StakeAction {
161    /// Delegate to a stake pool, registering the key first when it isn't.
162    Delegate(Hash<28>),
163    /// Delegate the vote, registering the key first when it isn't.
164    Vote(DRep),
165    /// Withdraw the rewards.
166    Withdraw,
167    /// Stop staking: withdraw the rewards, unregister the key, and get its
168    /// deposit back.
169    Stop,
170}
171
172/// Why the rewards can't be withdrawn: the vote isn't delegated.
173#[derive(Debug, Clone, Copy)]
174pub struct RewardsLocked;
175
176impl fmt::Display for RewardsLocked {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(
179            "Your rewards can't be withdrawn until you delegate your voting power: to a DRep, or always abstain",
180        )
181    }
182}
183
184impl std::error::Error for RewardsLocked {}
185
186/// What an account transaction does with the stake key and the DRep key
187/// besides paying: certificates, a reward withdrawal, and votes. Empty for a
188/// plain payment.
189#[derive(Debug, Clone, Default, PartialEq)]
190pub struct Staking {
191    pub certificates: Vec<Certificate>,
192    /// The reward account (its address bytes) and the rewards withdrawn.
193    pub withdrawal: Option<(Vec<u8>, u64)>,
194    /// Votes cast by the account's DRep: its key hash, and each governance
195    /// action's vote.
196    pub votes: Option<(Hash<28>, Vec<(GovActionId, Vote)>)>,
197}
198
199impl Staking {
200    /// Nothing: a plain payment.
201    pub fn none() -> Self {
202        Self::default()
203    }
204
205    /// The whole reward balance, withdrawn alongside a payment: nothing when
206    /// there are no rewards. Refused when the vote isn't delegated.
207    pub fn withdraw(key: &StakeKey, state: &StakeState) -> Result<Self> {
208        if !state.registered || state.rewards == 0 {
209            return Ok(Self::none());
210        }
211        if !state.votes {
212            bail!(RewardsLocked);
213        }
214        Ok(Staking {
215            withdrawal: Some((key.reward_account.clone(), state.rewards)),
216            ..Self::none()
217        })
218    }
219
220    /// The certificates and withdrawal `action` needs, the key standing as
221    /// `state` says. `key_deposit` is the current protocol parameter, paid
222    /// when the key is registered.
223    pub fn of(
224        key: &StakeKey,
225        action: &StakeAction,
226        state: &StakeState,
227        key_deposit: u64,
228    ) -> Result<Self> {
229        let cred = key.credential.clone();
230        let certificate = |c: Certificate| Staking {
231            certificates: vec![c],
232            ..Self::none()
233        };
234        Ok(match action {
235            StakeAction::Delegate(pool) if state.registered => {
236                certificate(Certificate::StakeDelegation(cred, *pool))
237            }
238            StakeAction::Delegate(pool) => {
239                certificate(Certificate::StakeRegDeleg(cred, *pool, key_deposit))
240            }
241            StakeAction::Vote(drep) if state.registered => {
242                certificate(Certificate::VoteDeleg(cred, drep.clone()))
243            }
244            StakeAction::Vote(drep) => {
245                certificate(Certificate::VoteRegDeleg(cred, drep.clone(), key_deposit))
246            }
247            StakeAction::Withdraw => {
248                if !state.registered || state.rewards == 0 {
249                    bail!("There are no rewards to withdraw");
250                }
251                Self::withdraw(key, state)?
252            }
253            StakeAction::Stop => {
254                if !state.registered {
255                    bail!("This account isn't staking");
256                }
257                let mut stop = Self::withdraw(key, state)?;
258                stop.certificates
259                    .push(Certificate::UnReg(cred, state.deposit));
260                stop
261            }
262        })
263    }
264
265    /// The certificates and votes `action` needs, the DRep standing as
266    /// `drep_state` says and the stake key as `stake_state` does. Registering
267    /// pays `params`' `drep_deposit`, and `key_deposit` too when the vote
268    /// delegation must register the stake key.
269    pub fn drep(
270        stake: &StakeKey,
271        stake_state: &StakeState,
272        drep: &DrepKey,
273        drep_state: &DrepState,
274        action: &DrepAction,
275        params: &ProtocolParameters,
276    ) -> Result<Self> {
277        let cred = drep.credential();
278        if !drep_state.registered && !matches!(action, DrepAction::Register { .. }) {
279            bail!("This account isn't a DRep: register it first");
280        }
281        let mut certificates = Vec::new();
282        let mut votes = None;
283        match action {
284            DrepAction::Register { anchor, delegate } => {
285                if drep_state.registered {
286                    bail!("This account is a DRep already");
287                }
288                let deposit = params.drep_deposit.ok_or_else(|| {
289                    anyhow!(
290                        "Koios didn't say what registering a DRep locks up. Nothing was built; try again later"
291                    )
292                })?;
293                certificates.push(Certificate::RegDRepCert(cred, deposit, nullable(anchor)));
294                if *delegate && !drep_state.own_vote {
295                    certificates.push(vote_delegation(stake, stake_state, drep.drep(), params));
296                }
297            }
298            DrepAction::Update { anchor } => {
299                certificates.push(Certificate::UpdateDRepCert(cred, nullable(anchor)));
300            }
301            DrepAction::Retire => {
302                // The ledger returns exactly what was paid, and refuses any
303                // other refund: an unknown deposit isn't guessed at as none.
304                if drep_state.deposit == 0 {
305                    bail!(
306                        "Koios didn't say what deposit this DRep paid, so its retirement can't be built. Nothing was built; try again later"
307                    );
308                }
309                certificates.push(Certificate::UnRegDRepCert(cred, drep_state.deposit));
310                if drep_state.own_vote && stake_state.registered {
311                    certificates.push(Certificate::VoteDeleg(
312                        stake.credential.clone(),
313                        DRep::Abstain,
314                    ));
315                }
316            }
317            DrepAction::Vote(ballots) => {
318                if ballots.is_empty() {
319                    bail!("Choose a vote on at least one governance action");
320                }
321                let mut sorted = ballots.clone();
322                sorted.sort_by(|(a, _), (b, _)| {
323                    (a.transaction_id, a.action_index).cmp(&(b.transaction_id, b.action_index))
324                });
325                if sorted.windows(2).any(|w| w[0].0 == w[1].0) {
326                    bail!("A governance action can have only one vote in a transaction");
327                }
328                votes = Some((drep.hash(), sorted));
329            }
330        }
331        Ok(Staking {
332            certificates,
333            withdrawal: None,
334            votes,
335        })
336    }
337
338    pub fn is_empty(&self) -> bool {
339        self.certificates.is_empty() && self.withdrawal.is_none() && self.votes.is_none()
340    }
341
342    /// Whether the stake key signs: for a stake certificate or a withdrawal.
343    pub fn stake_signs(&self) -> bool {
344        self.withdrawal.is_some() || self.certificates.iter().any(|c| !is_drep_certificate(c))
345    }
346
347    /// Whether the DRep key signs: for a DRep certificate or a vote.
348    pub fn drep_signs(&self) -> bool {
349        self.votes.is_some() || self.certificates.iter().any(is_drep_certificate)
350    }
351
352    /// How many keys sign besides the payment keys: the stake key, the DRep
353    /// key, or both.
354    pub fn signers(&self) -> usize {
355        usize::from(self.stake_signs()) + usize::from(self.drep_signs())
356    }
357
358    /// Lovelace the certificates lock up: a registration's deposit.
359    pub fn deposit(&self) -> u64 {
360        self.certificates
361            .iter()
362            .map(|c| match c {
363                Certificate::Reg(_, d)
364                | Certificate::StakeRegDeleg(_, _, d)
365                | Certificate::VoteRegDeleg(_, _, d)
366                | Certificate::StakeVoteRegDeleg(_, _, _, d)
367                | Certificate::RegDRepCert(_, d, _) => *d,
368                _ => 0,
369            })
370            .sum()
371    }
372
373    /// Of [`Self::deposit`], what registering a DRep locks up: it comes back
374    /// when the DRep retires, not when the stake key does.
375    pub fn drep_deposit(&self) -> u64 {
376        self.certificates
377            .iter()
378            .map(|c| match c {
379                Certificate::RegDRepCert(_, d, _) => *d,
380                _ => 0,
381            })
382            .sum()
383    }
384
385    /// Lovelace the certificates return: an unregistration's deposit.
386    pub fn refund(&self) -> u64 {
387        self.certificates
388            .iter()
389            .map(|c| match c {
390                Certificate::UnReg(_, d) | Certificate::UnRegDRepCert(_, d) => *d,
391                _ => 0,
392            })
393            .sum()
394    }
395
396    /// Rewards withdrawn.
397    pub fn withdrawn(&self) -> u64 {
398        self.withdrawal.as_ref().map_or(0, |(_, l)| *l)
399    }
400
401    /// What the inputs gain (rewards, a refund) less what they lose (a
402    /// deposit), for the transaction's balance; `None` below zero.
403    pub fn net(&self, inputs: u64) -> Option<u64> {
404        inputs
405            .checked_add(self.withdrawn())?
406            .checked_add(self.refund())?
407            .checked_sub(self.deposit())
408    }
409
410    /// Sets the certificates and withdrawal in `built`, an unsigned
411    /// transaction, and returns it with its new hash. Nothing to set leaves
412    /// it as it was.
413    pub fn patch(&self, mut built: BuiltTransaction) -> Result<BuiltTransaction> {
414        if self.is_empty() {
415            return Ok(built);
416        }
417        if built.signatures.is_some() {
418            bail!("A transaction is patched before it's signed, not after");
419        }
420        let mut tx = conway::Tx::decode_fragment(&built.tx_bytes.0)
421            .map_err(|e| anyhow!("The built transaction isn't a Conway transaction: {e}"))?;
422        let body = &mut tx.transaction_body;
423        if body.certificates.is_some()
424            || body.withdrawals.is_some()
425            || body.voting_procedures.is_some()
426        {
427            bail!("The transaction already has certificates, withdrawals or votes");
428        }
429        body.certificates = NonEmptySet::from_vec(self.certificates.clone());
430        body.withdrawals = self.withdrawal.as_ref().map(|(account, lovelace)| {
431            NonEmptyKeyValuePairs::Def(vec![(account.clone().into(), *lovelace)])
432        });
433        body.voting_procedures = self.votes.as_ref().map(|(drep, ballots)| {
434            let procedures = ballots
435                .iter()
436                .map(|(action, vote)| {
437                    (
438                        action.clone(),
439                        VotingProcedure {
440                            vote: vote.clone(),
441                            anchor: Nullable::Null,
442                        },
443                    )
444                })
445                .collect();
446            NonEmptyKeyValuePairs::Def(vec![(
447                Voter::DRepKey(*drep),
448                NonEmptyKeyValuePairs::Def(procedures),
449            )])
450        });
451        let bytes = tx
452            .encode_fragment()
453            .map_err(|e| anyhow!("Failed to encode the transaction: {e}"))?;
454        // Pallas doesn't export its byte types, but their fields are public.
455        built.tx_hash.0 = *tx_id(&bytes)?;
456        built.tx_bytes.0 = bytes;
457        Ok(built)
458    }
459}
460
461/// Whether a certificate is a DRep's own (signed by the DRep key), not the
462/// stake key's.
463fn is_drep_certificate(certificate: &Certificate) -> bool {
464    matches!(
465        certificate,
466        Certificate::RegDRepCert(..)
467            | Certificate::UnRegDRepCert(..)
468            | Certificate::UpdateDRepCert(..)
469    )
470}
471
472/// The stake key's vote delegation to `drep`, registering the key first
473/// when it isn't.
474fn vote_delegation(
475    stake: &StakeKey,
476    state: &StakeState,
477    drep: DRep,
478    params: &ProtocolParameters,
479) -> Certificate {
480    let cred = stake.credential.clone();
481    if state.registered {
482        Certificate::VoteDeleg(cred, drep)
483    } else {
484        Certificate::VoteRegDeleg(cred, drep, params.key_deposit)
485    }
486}
487
488fn nullable(anchor: &Option<Anchor>) -> Nullable<Anchor> {
489    match anchor {
490        Some(a) => Nullable::Some(a.clone()),
491        None => Nullable::Null,
492    }
493}
494
495/// The ledger's limit on an anchor's address, in bytes.
496pub const MAX_ANCHOR_URL: usize = 128;
497
498/// An anchor: where a document is published, and the blake2b-256 hash of its
499/// exact bytes (64 hex characters). The address is at most
500/// [`MAX_ANCHOR_URL`] bytes, with no spaces or control characters.
501pub fn anchor(url: &str, hash: &str) -> Result<Anchor> {
502    let url = url.trim();
503    if url.is_empty() {
504        bail!("Give the address where the file is published");
505    }
506    if url.len() > MAX_ANCHOR_URL {
507        bail!(
508            "That address is {} bytes, over the network's limit of {MAX_ANCHOR_URL}: use a shorter one",
509            url.len()
510        );
511    }
512    if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
513        bail!("An address can't hold spaces or control characters");
514    }
515    let bytes: [u8; 32] = hex::decode(hash.trim())
516        .ok()
517        .and_then(|b| b.try_into().ok())
518        .ok_or_else(|| anyhow!("A file's hash is 64 hex characters"))?;
519    Ok(Anchor {
520        url: url.to_string(),
521        content_hash: Hash::new(bytes),
522    })
523}
524
525/// A governance action by the transaction that proposed it (64 hex
526/// characters) and its index there.
527pub fn gov_action_id(tx_hash: &str, index: u32) -> Result<GovActionId> {
528    let bytes: [u8; 32] = hex::decode(tx_hash.trim())
529        .ok()
530        .and_then(|b| b.try_into().ok())
531        .ok_or_else(|| anyhow!("A governance action's transaction is 64 hex characters"))?;
532    Ok(GovActionId {
533        transaction_id: Hash::new(bytes),
534        action_index: index,
535    })
536}
537
538/// A stake pool's ID: bech32 (`pool1…`) or 56 hex characters.
539pub fn parse_pool_id(id: &str) -> Result<Hash<28>> {
540    let id = id.trim();
541    let bytes = if id.len() == 56 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
542        hex::decode(id).context("A pool ID in hex is 28 bytes")?
543    } else {
544        let (hrp, data) = bech32_of(id).ok_or_else(|| anyhow!("That isn't a stake pool ID"))?;
545        if hrp != "pool" {
546            bail!("That isn't a stake pool ID: pool IDs start pool1");
547        }
548        data
549    };
550    let hash: [u8; 28] = bytes
551        .try_into()
552        .map_err(|_| anyhow!("That isn't a stake pool ID: it's the wrong length"))?;
553    Ok(Hash::new(hash))
554}
555
556/// A pool's bech32 ID (`pool1…`).
557pub fn pool_id(hash: &Hash<28>) -> String {
558    bech32::encode("pool", hash.as_ref().to_base32(), Variant::Bech32)
559        .expect("a pool ID always encodes")
560}
561
562/// A vote delegation: [`ALWAYS_ABSTAIN`], [`ALWAYS_NO_CONFIDENCE`], or a
563/// DRep's ID. IDs are read in CIP-129 form (`drep1…` with a header byte, as
564/// Koios gives them) and CIP-105's (`drep1…` for a key, `drep_script1…`).
565pub fn parse_drep(id: &str) -> Result<DRep> {
566    let id = id.trim();
567    match id {
568        ALWAYS_ABSTAIN => return Ok(DRep::Abstain),
569        ALWAYS_NO_CONFIDENCE => return Ok(DRep::NoConfidence),
570        _ => {}
571    }
572    let (hrp, data) = bech32_of(id).ok_or_else(|| anyhow!("That isn't a DRep ID"))?;
573    let hash = |bytes: &[u8]| -> Result<Hash<28>> {
574        let hash: [u8; 28] = bytes
575            .try_into()
576            .map_err(|_| anyhow!("That isn't a DRep ID: it's the wrong length"))?;
577        Ok(Hash::new(hash))
578    };
579    match (hrp.as_str(), data.len()) {
580        ("drep", 29) => match data[0] {
581            DREP_KEY_HEADER => Ok(DRep::Key(hash(&data[1..])?)),
582            DREP_SCRIPT_HEADER => Ok(DRep::Script(hash(&data[1..])?)),
583            _ => bail!("That isn't a DRep ID: its header names something else"),
584        },
585        ("drep", 28) => Ok(DRep::Key(hash(&data)?)),
586        ("drep_script", 28) => Ok(DRep::Script(hash(&data)?)),
587        ("drep" | "drep_script", _) => bail!("That isn't a DRep ID: it's the wrong length"),
588        _ => bail!("That isn't a DRep ID: DRep IDs start drep1"),
589    }
590}
591
592/// A vote delegation as Koios names it: CIP-129 for a DRep, or one of the
593/// pinned two.
594pub fn drep_id(drep: &DRep) -> String {
595    let (header, hash) = match drep {
596        DRep::Abstain => return ALWAYS_ABSTAIN.to_string(),
597        DRep::NoConfidence => return ALWAYS_NO_CONFIDENCE.to_string(),
598        DRep::Key(h) => (DREP_KEY_HEADER, h),
599        DRep::Script(h) => (DREP_SCRIPT_HEADER, h),
600    };
601    let bytes: Vec<u8> = std::iter::once(header)
602        .chain(hash.as_ref().iter().copied())
603        .collect();
604    bech32::encode("drep", bytes.to_base32(), Variant::Bech32).expect("a DRep ID always encodes")
605}
606
607/// The human-readable part and bytes of a bech32 string, lowercase.
608fn bech32_of(text: &str) -> Option<(String, Vec<u8>)> {
609    let (hrp, data, variant) = bech32::decode(text).ok()?;
610    if variant != Variant::Bech32 {
611        return None;
612    }
613    Some((hrp, Vec::<u8>::from_base32(&data).ok()?))
614}