diff --git a/Cargo.lock b/Cargo.lock index 7255dbee..2d3a8ec1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4018,6 +4018,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -4486,6 +4495,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keycard_wallet" +version = "0.1.0" +dependencies = [ + "log", + "nssa", + "pyo3", + "serde", + "serde_json", +] + [[package]] name = "lazy-regex" version = "3.6.0" @@ -6057,6 +6077,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mempool" version = "0.1.0" @@ -7337,6 +7366,69 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.117", +] + [[package]] name = "quick-protobuf" version = "0.8.1" @@ -8151,6 +8243,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" +[[package]] +name = "rpassword" +version = "7.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rpds" version = "1.2.0" @@ -8243,6 +8346,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "ruint" version = "1.17.2" @@ -9266,6 +9379,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.26.0" @@ -10145,6 +10264,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -10326,11 +10451,14 @@ dependencies = [ "indicatif", "itertools 0.14.0", "key_protocol", + "keycard_wallet", "log", "nssa", "nssa_core", "optfield", + "pyo3", "rand 0.8.5", + "rpassword", "sequencer_service_rpc", "serde", "serde_json", @@ -10341,6 +10469,7 @@ dependencies = [ "token_core", "tokio", "url", + "zeroize", ] [[package]] @@ -10756,6 +10885,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index d3b0921c..f4a981ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ members = [ "examples/program_deployment/methods/guest", "testnet_initial_state", "indexer/ffi", + "keycard_wallet", "test_fixtures", "tools/cycle_bench", "tools/crypto_primitives_bench", @@ -77,6 +78,7 @@ faucet_core = { path = "programs/faucet/core" } vault_core = { path = "programs/vault/core" } test_program_methods = { path = "test_program_methods" } testnet_initial_state = { path = "testnet_initial_state" } +keycard_wallet = { path = "keycard_wallet" } test_fixtures = { path = "test_fixtures" } tokio = { version = "1.50", features = [ @@ -158,6 +160,7 @@ actix-web = { version = "4.13.0", default-features = false, features = [ ] } clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } +pyo3 = { version = "0.24", features = ["auto-initialize"] } # Profile for leptos WASM release builds [profile.wasm-release] diff --git a/artifacts/test_program_methods/malicious_injector.bin b/artifacts/test_program_methods/malicious_injector.bin index 40e57b81..55ac856b 100644 Binary files a/artifacts/test_program_methods/malicious_injector.bin and b/artifacts/test_program_methods/malicious_injector.bin differ diff --git a/artifacts/test_program_methods/malicious_launderer.bin b/artifacts/test_program_methods/malicious_launderer.bin index cdae83a3..ec15275c 100644 Binary files a/artifacts/test_program_methods/malicious_launderer.bin and b/artifacts/test_program_methods/malicious_launderer.bin differ diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md new file mode 100644 index 00000000..38feea4f --- /dev/null +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -0,0 +1,237 @@ +This tutorial walks you through using Keycard with Wallet CLI. Keycard is optional hardware that can offer enhance security to a LEZ wallet. A LEZ wallet that utilizes Keycard does not store any secret keys for public accounts (eventually, this will extend to private accounts). Instead, Wallet CLI retrieves the appropriate public keys and signatures from Keycard. + + +## Keycard Setup + +### Required hardware +- Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE. +- Smartcard reader +- Applets (`math.cap` and `LEE_keycard.cap`). Eventually, both of these applets will be available in separate repos. + - `math.cap` is an applet to speed up computations on Keycard; developed by Bitgamma (Keycard-tech team). + - `LEE_keycard.cap` is an applet that contains LEE keycard protocol; developed by Bitgamma (Keycard-tech team) + +### Firmware installation +Installation: + +1. Install math applet on your keycard; this process only needs to be done once. In the root of repo: + ``` + sudo apt-get install -y default-jdk + wget https://github.com/martinpaljak/GlobalPlatformPro/releases/download/v25.10.20/gp.jar -P keycard_wallet/keycard_applets + cd keycard_wallet/keycard_applets + java -jar gp.jar --key c212e073ff8b4bbfaff4de8ab655221f --load math.cap + ``` +2. Install `keycard-desktop` from [github](https://github.com/choppu/keycard-desktop) + - Keycard Desktop is used to install the LEE key protocol to a blank keycard. + - Select (Re)Install Applet and upload the key binary (`keycard_wallet/keycard_applets/LEE_keycard.cap`). + ![keycard-desktop.png](keycard-desktop.png) + - **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa. + +## Wallet with Keycard +Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `keycard_wallet/wallet_with_keycard.sh`. + +```bash +# Install appropriate version of `keycard-py`. +git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git keycard_wallet/python/keycard-py + +# Set up virtual environment. +python3 -m venv venv +source venv/bin/activate +pip install pyscard mnemonic ecdsa pyaes +pip install -e keycard_wallet/python/keycard-py +``` + +**Important**: Keycard wallet commands only work within the virtual environment. +```bash +# In the root of LEE repo: +source venv/bin/activate +``` + +## PIN entry + +Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable: + +```bash +export KEYCARD_PIN=123456 +``` + +Unset it when done: + +```bash +unset KEYCARD_PIN +``` + +## Keycard Commands + +### Keycard + +| Command | Description | +|-----------------------------|------------------------------------------------------------| +| `wallet keycard available` | Checks whether a Keycard reader and card are accessible | +| `wallet keycard init` | Initializes a blank Keycard with a PIN and a generated PUK | +| `wallet keycard connect` | Establishes and saves a pairing with the Keycard | +| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing | +| `wallet keycard load` | Loads a mnemonic phrase onto the Keycard | + +1. Check keycard availability +```bash +wallet keycard available + +# Output: +✅ Keycard is available. +``` + +2. Initialize a blank Keycard +```bash +wallet keycard init + +# Output: +Keycard PIN: +Keycard PUK: 847302916485 +Record this PUK and store it somewhere safe. It cannot be recovered. +✅ Keycard initialized successfully. +``` + +3. Connect (pair and save pairing for subsequent commands) +```bash +wallet keycard connect + +# Output: +Keycard PIN: +✅ Keycard paired and ready. +``` + +4. Load a mnemonic phrase +```bash +# Supply mnemonic via environment variable to avoid interactive prompt +export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then" +wallet keycard load +unset KEYCARD_MNEMONIC + +# Output: +Keycard PIN: +✅ Keycard is now connected to wallet. +✅ Mnemonic phrase loaded successfully. +``` + +5. Disconnect (unpair and clear saved pairing) +```bash +wallet keycard disconnect + +# Output: +Keycard PIN: +✅ Keycard unpaired and pairing cleared. +``` + +### Pinata (testnet) + +| Command | Description | +|-----------------------|--------------------------------------------------------------------------| +| `wallet pinata claim` | Claims a testnet pinata reward to a public or private recipient account | + +Note: The recipient account must be initialized with `wallet auth-transfer init` before claiming. + +`--to` accepts any of: +- A BIP32 key path — uses Keycard (e.g. `m/44'/60'/0'/0/0`) +- An account ID with privacy prefix (e.g. `Public/9bKm...`) +- An account label (e.g. `my-account`) + +1. Claim to a Keycard public account +```bash +wallet pinata claim --to "m/44'/60'/0'/0/0" + +# Output: +Keycard PIN: +Computing solution for pinata... +Found solution 989106 in 33.739525ms +Transaction hash is fd320c01f5469e62d2486afa1d9d5be39afcca0cd01d1575905b7acd95cf6397 +``` + +2. Claim to a local wallet account by label +```bash +wallet pinata claim --to my-account + +# Output: +Transaction hash is 2c8a4f1e903d5b76e80214c5b82e1d46a105e28930ad71bcce48f2d07b49a16f +``` + +### Authenticated-transfer program + +| Command | Description | +|-----------------------------|-------------------------------------------------------------------------------| +| `wallet auth-transfer init` | Registers an account with the auth-transfer program | +| `wallet auth-transfer send` | Sends native tokens between accounts | + +`--account-id` (for `init`) and `--from`/`--to` (for `send`) each accept any of: +- A BIP32 key path — uses Keycard (e.g. `m/44'/60'/0'/0/0`) +- An account ID with privacy prefix (e.g. `Public/9bKm...`) +- An account label (e.g. `my-account`) + +For `send`, foreign recipient accounts (not in the local wallet and not a Keycard path) do not need to sign — pass their account ID directly via `--to`. Shielded sends to foreign private accounts use `--to-npk`/`--to-vpk`. + +1. Initialize a Keycard public account +```bash +wallet auth-transfer init --account-id "m/44'/60'/0'/0/0" + +# Output: +Keycard PIN: +Transaction hash is 49c16940493e1618c393645c1211b5c793d405838221c29ac6562a8a4b11c5a7 +``` + +2. Send native tokens between two Keycard accounts +```bash +wallet auth-transfer send \ + --from "m/44'/60'/0'/0/0" \ + --to "m/44'/60'/0'/0/1" \ + --amount 40 + +# Output: +Keycard PIN: +Transaction hash is 1a9764ab20763dcc1ffb51c6e9badd5a6316a773759032ca48e0eee59caaf488 +``` + +3. Send native tokens from a Keycard account to a foreign account +```bash +wallet auth-transfer send \ + --from "m/44'/60'/0'/0/0" \ + --to "Public/9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \ + --amount 20 + +# Output: +Keycard PIN: +Transaction hash is 3e7b2a91cf804d56fe19084b3c8b25d07e8f243829bc50addf6e2c78b4b09d34 +``` + +4. Send native tokens from a Keycard account to a local wallet account by label +```bash +wallet auth-transfer send \ + --from "m/44'/60'/0'/0/0" \ + --to my-account \ + --amount 20 + +# Output: +Keycard PIN: +Transaction hash is 7d4c1b8e2f903a56fd19084b3c8b25d07e8f243829bc50addf6e2c78b4b09e45 +``` + +## Testing + +Tests for Keycard commands are in `keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected: + +```bash +bash keycard_wallet/tests/keycard_tests.sh +``` + +## SigningGroups + +`SigningGroups` (`wallet/src/signing.rs`) partitions a transaction's signers into two buckets — local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved. + +Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing. + +Foreign recipient accounts — those with no local key and no Keycard path — are silently skipped and require neither a signature nor a nonce. + +``` +SigningGroups { + local: [(AccountId, PrivateKey)], // signed in pure Rust + keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session +} +``` \ No newline at end of file diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index 22550bb0..0a6a9038 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use bytesize::ByteSize; use common::transaction::NSSATransaction; use integration_tests::{TestContext, config::SequencerPartialConfig}; @@ -66,7 +66,64 @@ impl TpsTestManager { Duration::from_secs_f64(number_transactions as f64 / self.target_tps as f64) } + /// Claim funds from each account's vault PDA into the account itself. + /// + /// `GenesisAction::SupplyAccount` funds vault PDAs (not accounts directly), so this step is + /// required before sending `authenticated_transfer` transactions from these accounts. + /// All claim transactions are submitted at once and then confirmed sequentially. + /// After this call every account has nonce 1, so `build_public_txs` must be called after it. + pub async fn claim_vault_funds( + &self, + sequencer_client: &sequencer_service_rpc::SequencerClient, + ) -> Result<()> { + let vault_program_id = Program::vault().id(); + + let mut tx_hashes = Vec::with_capacity(self.public_keypairs.len()); + for (private_key, account_id) in &self.public_keypairs { + let owner_vault_id = + vault_core::compute_vault_account_id(vault_program_id, *account_id); + let message = putx::Message::try_new( + vault_program_id, + vec![*account_id, owner_vault_id], + vec![Nonce(0_u128)], + vault_core::Instruction::Claim { amount: 10 }, + ) + .context("Failed to build vault claim message")?; + let witness_set = + nssa::public_transaction::WitnessSet::for_message(&message, &[private_key]); + let tx = PublicTransaction::new(message, witness_set); + let hash = sequencer_client + .send_transaction(NSSATransaction::Public(tx)) + .await + .context("Failed to submit vault claim")?; + tx_hashes.push(hash); + } + + let deadline = Instant::now() + Duration::from_secs(300); + for (i, tx_hash) in tx_hashes.iter().enumerate() { + loop { + anyhow::ensure!( + Instant::now() < deadline, + "Vault claims timed out after 5 minutes ({i}/{} confirmed)", + tx_hashes.len() + ); + let found = sequencer_client + .get_transaction(*tx_hash) + .await + .ok() + .flatten() + .is_some(); + if found { + break; + } + } + } + Ok(()) + } + /// Build a batch of public transactions to submit to the node. + /// + /// Must be called after `claim_vault_funds`, which sets each account's nonce to 1. pub fn build_public_txs(&self) -> Vec { // Create valid public transactions let program = Program::authenticated_transfer_program(); @@ -78,7 +135,7 @@ impl TpsTestManager { let message = putx::Message::try_new( program.id(), [pair[0].1, pair[1].1].to_vec(), - [Nonce(0_u128)].to_vec(), + [Nonce(1_u128)].to_vec(), authenticated_transfer_core::Instruction::Transfer { amount }, ) .unwrap(); @@ -127,6 +184,12 @@ pub async fn tps_test() -> Result<()> { .build() .await?; + // Genesis funds vault PDAs, not accounts directly. Claim into accounts before measuring. + tps_test + .claim_vault_funds(ctx.sequencer_client()) + .await + .context("Failed to claim vault funds for TPS accounts")?; + let target_time = tps_test.target_time(); info!( "TPS test begin. Target time is {target_time:?} for {num_transactions} transactions ({target_tps} TPS)" diff --git a/keycard_wallet/Cargo.toml b/keycard_wallet/Cargo.toml new file mode 100644 index 00000000..f8f3fd0b --- /dev/null +++ b/keycard_wallet/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "keycard_wallet" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +nssa.workspace = true +pyo3.workspace = true +log.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true \ No newline at end of file diff --git a/keycard_wallet/keycard_applets/LEE_keycard.cap b/keycard_wallet/keycard_applets/LEE_keycard.cap new file mode 100644 index 00000000..b44835c4 Binary files /dev/null and b/keycard_wallet/keycard_applets/LEE_keycard.cap differ diff --git a/keycard_wallet/keycard_applets/math.cap b/keycard_wallet/keycard_applets/math.cap new file mode 100644 index 00000000..b9c0e99f Binary files /dev/null and b/keycard_wallet/keycard_applets/math.cap differ diff --git a/keycard_wallet/python/keycard_wallet.py b/keycard_wallet/python/keycard_wallet.py new file mode 100644 index 00000000..7e18636a --- /dev/null +++ b/keycard_wallet/python/keycard_wallet.py @@ -0,0 +1,164 @@ +from smartcard.System import readers +from keycard.exceptions import APDUError, TransportError +from ecdsa import VerifyingKey, SECP256k1 + +from keycard.keycard import KeyCard + +from mnemonic import Mnemonic +from keycard import constants + +import keycard +import secrets + +DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" + +class KeycardWallet: + def __init__(self): + self.card = KeyCard() + + def _is_smart_card_reader_detected(self) -> bool: + try: + return len(readers()) > 0 + except Exception: + return False + + def _is_keycard_detected(self) -> bool: + try: + KeyCard().select() + return True + except (TransportError, APDUError, Exception): + # No readers, no card, or card doesn't respond. + return False + + def is_unpaired_keycard_available(self) -> bool: + if not self._is_smart_card_reader_detected(): + return False + elif not self._is_keycard_detected(): + return False + return True + + def initialize(self, pin: str) -> bool: + try: + self.card.select() + + if self.card.is_initialized: + raise RuntimeError("Card is already initialized") + + puk = ''.join(secrets.choice('0123456789') for _ in range(12)) + self.card.init(pin, puk, DEFAULT_PAIRING_PASSWORD) + print(f"Keycard PUK: {puk}") + print("Record this PUK and store it somewhere safe. It cannot be recovered.") + return True + except Exception as e: + raise RuntimeError(f"Error initializing keycard: {e}") from e + + def setup_communication(self, pin: str, password = DEFAULT_PAIRING_PASSWORD) -> bool: + self.card.select() + + if not self.card.is_initialized: + raise RuntimeError("Card is not initialized — run 'wallet keycard init' first") + + pairing_index, pairing_key = self.card.pair(password) + self.pairing_index = pairing_index + self.pairing_key = pairing_key + + try: + self.card.open_secure_channel(pairing_index, pairing_key) + self.card.verify_pin(pin) + except Exception as e: + try: + self.card.unpair(pairing_index) + except Exception: + pass + raise RuntimeError(f"Error setting up communication: {e}") from e + + return True + + def get_pairing_data(self) -> tuple[int, bytes]: + return (self.pairing_index, self.pairing_key) + + def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: + self.card.select() + + if not self.card.is_initialized: + raise RuntimeError("Card is not initialized — run 'wallet keycard init' first") + + self.pairing_index = pairing_index + self.pairing_key = pairing_key + + try: + self.card.open_secure_channel(pairing_index, pairing_key) + self.card.verify_pin(pin) + except Exception as e: + raise RuntimeError(f"Error setting up communication with stored pairing: {e}") from e + + return True + + def close_session(self) -> bool: + return True + + def load_mnemonic(self, mnemonic: str) -> bool: + try: + # Convert mnemonic to seed + mnemo = Mnemonic("english") + if not mnemo.check(mnemonic): + raise RuntimeError("Invalid mnemonic phrase — check spelling and word count") + seed = mnemo.to_seed(mnemonic) + + # Load the LEE seed onto the card + result = self.card.load_key( + key_type = constants.LoadKeyType.LEE_SEED, + lee_seed = seed + ) + return True + except Exception as e: + raise RuntimeError(f"Error loading mnemonic: {e}") from e + + def disconnect(self) -> bool: + try: + if not self.card.is_secure_channel_open: + return False + + self.card.unpair(self.pairing_index) + + return True + except Exception as e: + raise RuntimeError(f"Error during disconnect: {e}") from e + + def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: + try: + if not self.card.is_secure_channel_open or not self.card.is_pin_verified: + return None + + public_key = self.card.export_key( + derivation_option = constants.DerivationOption.DERIVE, + public_only = True, + keypath = path + ) + + public_key = public_key.public_key + public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1) + public_key = public_key.to_string("compressed")[1:] + + return public_key + + except Exception as e: + raise RuntimeError(f"Error getting public key: {e}") from e + + + def sign_message_for_path(self, message: bytes, path: str = "m/44'/60'/0'/0/0") -> bytes | None: + try: + if not self.card.is_secure_channel_open or not self.card.is_pin_verified: + return None + + signature = self.card.sign_with_path( + digest = message, + path = path, + algorithm = constants.SigningAlgorithm.SCHNORR_BIP340, + make_current = False + ) + + return signature.signature + + except Exception as e: + raise RuntimeError(f"Error signing message: {e}") from e \ No newline at end of file diff --git a/keycard_wallet/src/lib.rs b/keycard_wallet/src/lib.rs new file mode 100644 index 00000000..134b6538 --- /dev/null +++ b/keycard_wallet/src/lib.rs @@ -0,0 +1,230 @@ +use std::path::PathBuf; + +use nssa::{AccountId, PublicKey, Signature}; +use pyo3::{prelude::*, types::PyAny}; +use serde::{Deserialize, Serialize}; + +pub mod python_path; + +// TODO: encrypt at rest alongside broader wallet storage encryption work. +#[derive(Serialize, Deserialize)] +pub struct KeycardPairingData { + pub index: u8, + pub key: Vec, +} + +impl KeycardPairingData { + const fn is_valid(&self) -> bool { + self.key.len() == 32 && self.index <= 4 + } +} + +/// Rust wrapper around the Python `KeycardWallet` class. +pub struct KeycardWallet { + instance: Py, +} + +impl KeycardWallet { + /// Create a new Python `KeycardWallet` instance. + pub fn new(py: Python) -> PyResult { + let module = py.import("keycard_wallet")?; + let class = module.getattr("KeycardWallet")?; + + let instance = class.call0()?; + + Ok(Self { + instance: instance.into(), + }) + } + + pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult { + self.instance + .bind(py) + .call_method0("is_unpaired_keycard_available")? + .extract() + } + + pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult { + self.instance + .bind(py) + .call_method1("initialize", (pin,))? + .extract() + } + + pub fn get_pairing_data(&self, py: Python<'_>) -> PyResult<(u8, Vec)> { + self.instance + .bind(py) + .call_method0("get_pairing_data")? + .extract() + } + + pub fn setup_communication_with_pairing( + &self, + py: Python<'_>, + pin: &str, + index: u8, + key: &[u8], + ) -> PyResult { + self.instance + .bind(py) + .call_method1( + "setup_communication_with_pairing", + (pin, index, key.to_vec()), + )? + .extract() + } + + pub fn close_session(&self, py: Python<'_>) -> PyResult { + self.instance + .bind(py) + .call_method0("close_session")? + .extract() + } + + /// Connect using a stored pairing if available, falling back to a fresh pair. + /// Saves any newly established pairing to disk. + pub fn connect(&self, py: Python<'_>, pin: &str) -> PyResult<()> { + if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid) + && self + .setup_communication_with_pairing(py, pin, pairing.index, &pairing.key) + .is_ok() + { + return Ok(()); + } + self.setup_communication(py, pin)?; + if let Ok((index, key)) = self.get_pairing_data(py) { + save_pairing(&KeycardPairingData { index, key }); + } + Ok(()) + } + + pub fn setup_communication(&self, py: Python<'_>, pin: &str) -> PyResult { + self.instance + .bind(py) + .call_method1("setup_communication", (pin,))? + .extract() + } + + pub fn disconnect(&self, py: Python) -> PyResult { + self.instance.bind(py).call_method0("disconnect")?.extract() + } + + pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult { + let public_key: Vec = self + .instance + .bind(py) + .call_method1("get_public_key_for_path", (path,))? + .extract()?; + + let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec| { + PyErr::new::(format!( + "expected 32-byte public key from keycard, got {} bytes", + vec.len() + )) + })?; + + PublicKey::try_new(public_key) + .map_err(|e| PyErr::new::(e.to_string())) + } + + pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult { + Python::with_gil(|py| { + python_path::add_python_path(py)?; + let wallet = Self::new(py)?; + wallet.connect(py, pin)?; + let pub_key = wallet.get_public_key_for_path(py, path); + drop(wallet.close_session(py)); + pub_key + }) + } + + pub fn sign_message_for_path( + &self, + py: Python, + path: &str, + message: &[u8; 32], + ) -> PyResult<(Signature, PublicKey)> { + let py_signature: Vec = self + .instance + .bind(py) + .call_method1("sign_message_for_path", (message, path))? + .extract()?; + + let signature: [u8; 64] = py_signature.try_into().map_err(|vec: Vec| { + PyErr::new::(format!( + "Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})", + vec.len(), + vec + )) + })?; + + let sig = Signature { value: signature }; + let pub_key = self.get_public_key_for_path(py, path)?; + if !sig.is_valid_for(message, &pub_key) { + return Err(PyErr::new::( + "keycard returned a signature that does not verify against its own public key", + )); + } + Ok((sig, pub_key)) + } + + pub fn sign_message_for_path_with_connect( + pin: &str, + path: &str, + message: &[u8; 32], + ) -> PyResult<(Signature, PublicKey)> { + Python::with_gil(|py| { + python_path::add_python_path(py)?; + let wallet = Self::new(py)?; + wallet.connect(py, pin)?; + let result = wallet.sign_message_for_path(py, path, message); + drop(wallet.close_session(py)); + result + }) + } + + pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> { + self.instance + .bind(py) + .call_method1("load_mnemonic", (mnemonic,))?; + Ok(()) + } + + pub fn get_account_id_for_path_with_connect(pin: &str, key_path: &str) -> PyResult { + let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?; + + Ok(format!("Public/{}", AccountId::from(&public_key))) + } +} + +fn pairing_file_path() -> Option { + let home = std::env::var("NSSA_WALLET_HOME_DIR") + .map(PathBuf::from) + .or_else(|_| { + std::env::home_dir() + .map(|h| h.join(".nssa").join("wallet")) + .ok_or(()) + }) + .ok()?; + Some(home.join("keycard_pairing.json")) +} + +fn load_pairing() -> Option { + let path = pairing_file_path()?; + let file = std::fs::File::open(path).ok()?; + serde_json::from_reader(file).ok() +} + +fn save_pairing(data: &KeycardPairingData) { + if let Some(path) = pairing_file_path() + && let Ok(json) = serde_json::to_vec_pretty(data) + { + drop(std::fs::write(path, json)); + } +} + +pub fn clear_pairing() { + if let Some(path) = pairing_file_path() { + drop(std::fs::remove_file(path)); + } +} diff --git a/keycard_wallet/src/python_path.rs b/keycard_wallet/src/python_path.rs new file mode 100644 index 00000000..8251f7a3 --- /dev/null +++ b/keycard_wallet/src/python_path.rs @@ -0,0 +1,63 @@ +use std::{env, path::PathBuf}; + +use pyo3::{prelude::*, types::PyList}; + +/// Adds the project's `python/` directory and venv site-packages to Python's sys.path. +pub fn add_python_path(py: Python<'_>) -> PyResult<()> { + let current_dir = env::current_dir().expect("Failed to get current working directory"); + + let python_base = env::var("VIRTUAL_ENV") + .ok() + .and_then(|v| PathBuf::from(v).parent().map(PathBuf::from)) + .unwrap_or_else(|| current_dir.clone()); + + let mut paths_to_add: Vec = vec![ + python_base.join("keycard_wallet").join("python"), + python_base + .join("keycard_wallet") + .join("python") + .join("keycard-py"), + ]; + + // If a virtualenv is active, add its site-packages so that dependencies + // installed in the venv (e.g. smartcard, ecdsa) are importable by the + // pyo3 embedded interpreter, which does not inherit sys.path from the + // shell's `python3` executable. + if let Ok(venv) = env::var("VIRTUAL_ENV") { + let lib = PathBuf::from(&venv).join("lib"); + if let Ok(entries) = std::fs::read_dir(&lib) { + for entry in entries.flatten() { + let site_packages = entry.path().join("site-packages"); + if site_packages.exists() { + paths_to_add.push(site_packages); + } + } + } + } + + // Sanity check — warns early if a path doesn't exist + for path in &paths_to_add { + if !path.exists() { + log::info!("Warning: Python path does not exist: {}", path.display()); + } + } + + let sys = PyModule::import(py, "sys")?; + let binding = sys.getattr("path")?; + let sys_path = binding.downcast::()?; + + for path in &paths_to_add { + let path_str = path.to_str().expect("Invalid path"); + + // Avoid duplicating the path + let already_present = sys_path + .iter() + .any(|p| p.extract::<&str>().map(|s| s == path_str).unwrap_or(false)); + + if !already_present { + sys_path.insert(0, path_str)?; + } + } + + Ok(()) +} diff --git a/keycard_wallet/tests/keycard_tests.sh b/keycard_wallet/tests/keycard_tests.sh new file mode 100755 index 00000000..e5ac2f2c --- /dev/null +++ b/keycard_wallet/tests/keycard_tests.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Run wallet_with_keycard.sh first + +source venv/bin/activate # Load the appropriate virtual environment + +export KEYCARD_PIN=111111 + +# Tests wallet keycard available +# - Checks whether smart reader and keycard are both available. +echo "Test: wallet keycard available" +wallet keycard available + +# Install a new mnemonic phrase to keycard +echo "Test: wallet keycard load" +export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then" +wallet keycard load +unset KEYCARD_MNEMONIC + +echo "Test: wallet auth-transfer init --account-id \"m/44'/60'/0'/0/0\"" +wallet auth-transfer init --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet pinata claim --to \"m/44'/60'/0'/0/0\"" +wallet pinata claim --to "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet auth-transfer init and send between two keycard accounts" +wallet auth-transfer init --account-id "m/44'/60'/0'/0/1" +wallet auth-transfer send --amount 40 --from "m/44'/60'/0'/0/0" --to "m/44'/60'/0'/0/1" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\"" +wallet account get --account-id "m/44'/60'/0'/0/1" + +# Send from keycard account to a local wallet account +echo "Test: create local wallet account" +LOCAL_ACCOUNT_ID=$(wallet account new public 2>&1 | grep -oP '(?<=Public/)\S+') +echo "Created local account: Public/${LOCAL_ACCOUNT_ID}" + +echo "Test: wallet auth-transfer init local account" +wallet auth-transfer init --account-id "Public/${LOCAL_ACCOUNT_ID}" + + +echo "Test: wallet auth-transfer send from keycard to local account" +wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/${LOCAL_ACCOUNT_ID}" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\"" +wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}" + +# Create a local wallet account, fund it, and send to keycard account (co-signed: local key + keycard) + +echo "Test: wallet auth-transfer send from local account to keycard account" +wallet auth-transfer send --amount 10 --from "Public/${LOCAL_ACCOUNT_ID}" --to "m/44'/60'/0'/0/1" + +echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\"" +wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\"" +wallet account get --account-id "m/44'/60'/0'/0/1" + +# Send from keycard account to a local wallet account (foreign recipient — no signature needed) +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" + +echo "Test: wallet auth-transfer send from keycard to local account" +wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" diff --git a/keycard_wallet/wallet_with_keycard.sh b/keycard_wallet/wallet_with_keycard.sh new file mode 100755 index 00000000..bcd87bd3 --- /dev/null +++ b/keycard_wallet/wallet_with_keycard.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +cargo install --path wallet --force + +# Install appropriate version of `keycard-py`. +git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git keycard_wallet/python/keycard-py + +# Set up virtual environment. +python3 -m venv venv +source venv/bin/activate +pip install pyscard mnemonic ecdsa pyaes +pip install -e keycard_wallet/python/keycard-py \ No newline at end of file diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 2c9dfb3a..cee4d5f8 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -326,12 +326,22 @@ impl TestContextBuilder { let initial_public_accounts = config::default_public_accounts_for_wallet(); let initial_private_accounts = config::default_private_accounts_for_wallet(); + // Wallet genesis must always be present so that + // setup_public/private_accounts_with_initial_supply can claim from the vault PDAs. + // When a test supplies custom genesis, merge rather than replace. + let wallet_genesis = + config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts); + let genesis = match genesis_transactions { + Some(mut custom) => { + custom.extend(wallet_genesis); + custom + } + None => wallet_genesis, + }; let (sequencer_handle, temp_sequencer_dir) = setup_sequencer( sequencer_partial_config.unwrap_or_default(), bedrock_addr, - genesis_transactions.unwrap_or_else(|| { - config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts) - }), + genesis, ) .await .context("Failed to setup Sequencer")?; diff --git a/wallet-ffi/src/transfer.rs b/wallet-ffi/src/transfer.rs index f2cadacc..8f3c47d7 100644 --- a/wallet-ffi/src/transfer.rs +++ b/wallet-ffi/src/transfer.rs @@ -3,7 +3,10 @@ use std::{ffi::CString, ptr}; use nssa::AccountId; -use wallet::program_facades::native_token_transfer::NativeTokenTransfer; +use wallet::{ + account::AccountIdWithPrivacy, cli::CliAccountMention, + program_facades::native_token_transfer::NativeTokenTransfer, +}; use crate::{ block_on, @@ -72,7 +75,16 @@ pub unsafe extern "C" fn wallet_ffi_transfer_public( let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.send_public_transfer(from_id, to_id, amount)) { + let from_mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(from_id)); + let to_mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(to_id)); + + match block_on(transfer.send_public_transfer( + from_id, + to_id, + amount, + &from_mention, + &to_mention, + )) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -591,7 +603,9 @@ pub unsafe extern "C" fn wallet_ffi_register_public_account( let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.register_account(account_id)) { + let mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id)); + + match block_on(transfer.register_account(account_id, &mention)) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index ed6fc1c5..3aaa1753 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -19,6 +19,10 @@ amm_core.workspace = true testnet_initial_state.workspace = true ata_core.workspace = true bip39.workspace = true +pyo3.workspace = true +rpassword = "7" +zeroize = "1" +keycard_wallet.workspace = true anyhow.workspace = true thiserror.workspace = true diff --git a/wallet/src/cli/keycard.rs b/wallet/src/cli/keycard.rs new file mode 100644 index 00000000..ead1e84b --- /dev/null +++ b/wallet/src/cli/keycard.rs @@ -0,0 +1,136 @@ +use anyhow::Result; +use clap::Subcommand; +use keycard_wallet::{KeycardWallet, clear_pairing, python_path}; +use pyo3::prelude::*; + +use crate::{ + WalletCore, + cli::{SubcommandReturnValue, WalletSubcommand, read_mnemonic, read_pin}, +}; + +/// Represents generic chain CLI subcommand. +#[derive(Subcommand, Debug, Clone)] +pub enum KeycardSubcommand { + Available, + Connect, + Disconnect, + Init, + Load, +} + +impl WalletSubcommand for KeycardSubcommand { + async fn handle_subcommand( + self, + _wallet_core: &mut WalletCore, + ) -> Result { + match self { + Self::Available => { + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py) + .expect("`wallet::keycard::available`: invalid data received for pin"); + let available = wallet.is_unpaired_keycard_available(py).expect( + "`wallet::keycard::available`: received invalid data from Keycard wrapper", + ); + + if available { + println!("\u{2705} Keycard is available."); + } else { + println!("\u{274c} Keycard is not available."); + } + }); + + Ok(SubcommandReturnValue::Empty) + } + Self::Connect => { + let pin = read_pin()?; + + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py) + .expect("`wallet::keycard::connect`: invalid keycard wallet provided"); + + wallet + .connect(py, &pin) + .expect("`wallet::keycard::connect`: failed to connect to keycard"); + + println!("\u{2705} Keycard paired and ready."); + drop(wallet.close_session(py)); + }); + + Ok(SubcommandReturnValue::Empty) + } + Self::Disconnect => { + let pin = read_pin()?; + + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py) + .expect("`wallet::keycard::disconnect`: invalid keycard wallet provided"); + + wallet + .connect(py, &pin) + .expect("`wallet::keycard::disconnect`: failed to open session"); + + wallet + .disconnect(py) + .expect("`wallet::keycard::disconnect`: failed to unpair keycard"); + + clear_pairing(); + println!("\u{2705} Keycard unpaired and pairing cleared."); + }); + + Ok(SubcommandReturnValue::Empty) + } + Self::Init => { + let pin = read_pin()?; + + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py) + .expect("`wallet::keycard::init`: invalid keycard wallet provided"); + + let initialized = wallet + .initialize(py, &pin) + .expect("`wallet::keycard::init`: failed to initialize keycard"); + + if initialized { + clear_pairing(); + println!("\u{2705} Keycard initialized successfully."); + } + }); + + Ok(SubcommandReturnValue::Empty) + } + Self::Load => { + let pin = read_pin()?; + let mnemonic = read_mnemonic()?; + + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py) + .expect("`wallet::keycard::load`: invalid keycard wallet provided"); + + wallet + .connect(py, &pin) + .expect("`wallet::keycard::load`: failed to connect to keycard"); + + println!("\u{2705} Keycard is now connected to wallet."); + if wallet.load_mnemonic(py, &mnemonic).is_ok() { + println!("\u{2705} Mnemonic phrase loaded successfully."); + } else { + println!("\u{274c} Failed to load mnemonic phrase."); + } + drop(wallet.close_session(py)); + }); + + Ok(SubcommandReturnValue::Empty) + } + } + } +} diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 22e8333f..8acdfa67 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -9,6 +9,7 @@ use futures::TryFutureExt as _; use nssa::{ProgramDeploymentTransaction, program::Program}; use sequencer_service_rpc::RpcClient as _; +pub use crate::helperfunctions::{read_mnemonic, read_pin}; use crate::{ WalletCore, account::{AccountIdWithPrivacy, Label}, @@ -17,6 +18,7 @@ use crate::{ chain::ChainSubcommand, config::ConfigSubcommand, group::GroupSubcommand, + keycard::KeycardSubcommand, programs::{ amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, @@ -30,6 +32,7 @@ pub mod account; pub mod chain; pub mod config; pub mod group; +pub mod keycard; pub mod programs; pub(crate) trait WalletSubcommand { @@ -81,6 +84,9 @@ pub enum Command { }, /// Deploy a program. DeployProgram { binary_filepath: PathBuf }, + /// Keycard hardware wallet management. + #[command(subcommand)] + Keycard(KeycardSubcommand), } /// To execute commands, env var `NSSA_WALLET_HOME_DIR` must be set into directory with config. @@ -113,10 +119,13 @@ pub enum SubcommandReturnValue { } #[derive(Debug, Display, Clone, PartialEq, Eq, Hash)] -#[display("{_0}")] pub enum CliAccountMention { + #[display("{_0}")] Id(AccountIdWithPrivacy), + #[display("{_0}")] Label(Label), + #[display("{_0}")] + KeyPath(String), } impl CliAccountMention { @@ -126,6 +135,14 @@ impl CliAccountMention { Self::Label(label) => storage .resolve_label(label) .ok_or_else(|| anyhow::anyhow!("No account found for label `{label}`")), + Self::KeyPath(path) => { + let pin = read_pin()?; + let id_str = + keycard_wallet::KeycardWallet::get_account_id_for_path_with_connect(&pin, path) + .map_err(anyhow::Error::from)?; + AccountIdWithPrivacy::from_str(&id_str) + .map_err(|e| anyhow::anyhow!("Invalid account id from keycard: {e}")) + } } } } @@ -134,6 +151,9 @@ impl FromStr for CliAccountMention { type Err = std::convert::Infallible; fn from_str(s: &str) -> std::result::Result { + if s.starts_with("m/") { + return Ok(Self::KeyPath(s.to_owned())); + } AccountIdWithPrivacy::from_str(s).map_or_else( |_| Ok(Self::Label(Label::new(s.to_owned()))), |account_id| Ok(Self::Id(account_id)), @@ -147,6 +167,12 @@ impl From