use anyhow::Result; use keycard_wallet::{KeycardWallet, python_path}; use nssa::{AccountId, PrivateKey, PublicKey, Signature}; use pyo3::Python; use crate::{WalletCore, cli::CliAccountMention}; /// Groups transaction signers by type to minimise Python GIL acquisition. /// /// Local signers are signed in pure Rust; all keycard signers share a single Python session /// with one `connect` / `close_session` pair. #[derive(Default)] pub struct SigningGroup { local: Vec<(AccountId, PrivateKey)>, keycard: Vec<(AccountId, String)>, } impl SigningGroup { #[must_use] pub fn new() -> Self { Self::default() } /// Add a sender. Keycard paths are queued for the hardware session; local accounts /// have their signing key resolved eagerly. Errors if no key is found. pub fn add_required( &mut self, mention: &CliAccountMention, account_id: AccountId, wallet_core: &WalletCore, ) -> Result<()> { if let CliAccountMention::KeyPath(path) = mention { self.keycard.push((account_id, path.clone())); return Ok(()); } let key = wallet_core .storage() .key_chain() .pub_account_signing_key(account_id) .ok_or_else(|| anyhow::anyhow!("signing key not found for account {account_id}"))? .clone(); self.local.push((account_id, key)); Ok(()) } /// Add a recipient. Same as [`add_required`] but silently skips accounts with no local /// key and no keycard path — they are foreign and require neither a signature nor a nonce. pub fn add_optional( &mut self, mention: &CliAccountMention, account_id: AccountId, wallet_core: &WalletCore, ) -> Result<()> { if let CliAccountMention::KeyPath(path) = mention { self.keycard.push((account_id, path.clone())); return Ok(()); } if let Some(key) = wallet_core .storage() .key_chain() .pub_account_signing_key(account_id) { self.local.push((account_id, key.clone())); } Ok(()) } /// Returns `true` when a PIN is required (at least one keycard signer is present). #[must_use] pub const fn needs_pin(&self) -> bool { !self.keycard.is_empty() } /// Account IDs that require a nonce (every non-foreign signer). #[must_use] pub fn signing_ids(&self) -> Vec { self.local .iter() .map(|(id, _)| *id) .chain(self.keycard.iter().map(|(id, _)| *id)) .collect() } /// Sign `hash` for every account in the group. /// /// Local accounts are signed in pure Rust. Keycard accounts share one Python session. pub fn sign_all(&self, hash: &[u8; 32], pin: &str) -> Result> { let mut sigs: Vec<(Signature, PublicKey)> = self .local .iter() .map(|(_, key)| { ( Signature::new(key, hash), PublicKey::new_from_private_key(key), ) }) .collect(); if !self.keycard.is_empty() { pyo3::Python::with_gil(|py| -> pyo3::PyResult<()> { python_path::add_python_path(py)?; let wallet = KeycardWallet::new(py)?; wallet.connect(py, pin)?; for (_, path) in &self.keycard { sigs.push(wallet.sign_message_for_path(py, path, hash)?); } drop(wallet.close_session(py)); Ok(()) }) .map_err(anyhow::Error::from)?; } Ok(sigs) } } /// Lazily opens and reuses a single Keycard session for all keycard signers in one transaction. pub struct KeycardSessionContext { pin: String, wallet: Option, } impl KeycardSessionContext { pub fn new(pin: impl Into) -> Self { Self { pin: pin.into(), wallet: None, } } pub fn get_or_connect<'py>( &'py mut self, py: Python<'py>, ) -> pyo3::PyResult<&'py KeycardWallet> { if self.wallet.is_none() { python_path::add_python_path(py)?; let wallet = KeycardWallet::new(py)?; wallet.connect(py, &self.pin)?; self.wallet = Some(wallet); } Ok(self.wallet.as_ref().unwrap()) } pub fn close(self, py: Python<'_>) { if let Some(w) = self.wallet { drop(w.close_session(py)); } } }