diff --git a/.gitignore b/.gitignore index b265e9aa..4605856c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ result wallet-ffi/wallet_ffi.h bedrock_signing_key integration_tests/configs/debug/ +venv/ +keycard_wallet/python/__pycache__/ +keycard_wallet/python/keycard-py/ diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md index c453c901..c01574b4 100644 --- a/docs/LEZ testnet v0.1 tutorials/keycard.md +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -60,6 +60,29 @@ Unset it when done: unset KEYCARD_PIN ``` +## Pairing password + +The pairing password is used to establish a secure channel between the wallet and the card. It is set permanently on the card during `wallet keycard init` and must match on every subsequent re-pair. + +The default password (`KeycardDefaultPairing`) is [recommended](https://docs.keycard.tech/en/developers/core) for most users. Wallet CLI allows advance users the flexibility to set their own pairing password. + +To use a custom pairing password, set it before `init`: + +```bash +export KEYCARD_PAIRING_PASSWORD=my-custom-password +wallet keycard init +``` + +After a successful initializaation, subsequent commands (`connect`, transfers) use the cached pairing index and key — the pairing password is not needed again until the pairing is cleared. + +**Important:** if you initialized with a custom password, `KEYCARD_PAIRING_PASSWORD` must be set in every session where re-pairing can occur (after `disconnect`, or on a new machine). If the env var is missing then wallet CLI will attempt to use the default password. As a result, pairing will fail. + +Unset the pairing password variable when done: + +```bash +unset KEYCARD_PAIRING_PASSWORD +``` + ## Keycard Commands ### Keycard @@ -473,22 +496,34 @@ Transaction data is ... ## Testing -Tests for Keycard commands are in `keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected: +Tests for Keycard commands are in `keycard_wallet/tests/`. + +| Test file | Description | +|---|---| +| `keycard_tests.sh` | Core Keycard wallet commands and `auth-transfer` commands | +| `keycard_tests_2.sh` | Tests Keycard wallet commands for `amma`, `token` and `ata` programs | +| `keycard_test_3.sh` | Demonstrates retrieving private account keys from keycard | +| `keycard_power_recovery_tests.sh` | Modified test file of `keycard_tests.sh` to test power recovery paths | + +Run from the repo root with a Keycard connected: ```bash bash keycard_wallet/tests/keycard_tests.sh +bash keycard_wallet/tests/keycard_tests_2.sh +bash keycard_wallet/tests/keycard_test_3.sh +bash keycard_wallet/tests/keycard_power_recovery_tests.sh ``` -## SigningGroups +## SigningGroup -`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. +`SigningGroup` (`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 { +SigningGroup { local: [(AccountId, PrivateKey)], // signed in pure Rust keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session } diff --git a/keycard_tests.sh b/keycard_tests.sh deleted file mode 100644 index d336e5fc..00000000 --- a/keycard_tests.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -# Run wallet_with_keycard.sh first - -source venv/bin/activate # Load the appropriate virtual environment - -source venv/bin/activate -export KEYCARD_PIN=111111 - -# ============================================================================= -# Keycard setup -# ============================================================================= -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" - -echo "=== Test: account get path 0 ===" -wallet account get --account-id "m/44'/60'/0'/0/0" -echo "=== Test: account get path 1 ===" -wallet account get --account-id "m/44'/60'/0'/0/1" -echo "" -echo "=== Test (1): Shielded auth-transfer to owned private account ===" - -wallet auth-transfer send --amount 2 \ - --from "m/44'/60'/0'/0/0" \ - --to-npk "55204e2934045b044f06d8222b454d46b54788f33c7dec4f6733d441703bb0e6" \ - --to-vpk "02a8626b0c0ad9383c5678dad48c3969b4174fb377cdb03a6259648032c774cec8" -echo "Shielded auth-transfer sent" - -sleep 15 -wallet account get --account-id "m/44'/60'/0'/0/0" - -# ============================================================================= -# Test (2): Deshielded auth-transfer — private account → keycard recipient -# A fresh private account is funded, then sends native tokens to keycard -# path 1. The private sender is handled by the ZK circuit; the keycard -# recipient does not sign — resolve() derives its account ID from the card. -# ============================================================================= -echo "" -echo "=== Test (2): Deshielded auth-transfer: private account → keycard path 1 ===" - -PRIV_SENDER=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1) -echo "Fresh private sender account: $PRIV_SENDER" - -wallet auth-transfer init --account-id "$PRIV_SENDER" - -echo "Test: wallet pinata claim to private sender" -wallet pinata claim --to "$PRIV_SENDER" - -sleep 15 - -echo "priv-sender state after claim:" -wallet account get --account-id "$PRIV_SENDER" - -wallet auth-transfer send \ - --from "$PRIV_SENDER" \ - --to "m/44'/60'/0'/0/1" \ - --amount 5 -echo "Deshielded transfer of 5: $PRIV_SENDER → keycard path 1" - -sleep 15 - -echo "priv-sender state (balance should have decreased by 5):" -wallet account get --account-id "$PRIV_SENDER" -echo "Keycard path 1 state (balance should have increased by 5):" -wallet account get --account-id "m/44'/60'/0'/0/1" \ No newline at end of file diff --git a/keycard_wallet/python/keycard_wallet.py b/keycard_wallet/python/keycard_wallet.py index 1048ebbc..b8f60279 100644 --- a/keycard_wallet/python/keycard_wallet.py +++ b/keycard_wallet/python/keycard_wallet.py @@ -4,14 +4,18 @@ from ecdsa import VerifyingKey, SECP256k1 from keycard.keycard import KeyCard from keycard.commands.export_lee_key import export_lee_key -from mnemonic import Mnemonic -from keycard import constants - +from mnemonic import Mnemonic +from keycard import constants + import keycard +import os import secrets DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" +def _pairing_password() -> str: + return os.environ.get("KEYCARD_PAIRING_PASSWORD", DEFAULT_PAIRING_PASSWORD) + class KeycardWallet: def __init__(self): self.card = KeyCard() @@ -37,7 +41,7 @@ class KeycardWallet: return False return True - def initialize(self, pin: str) -> bool: + def initialize(self, pin: str, pairing_password: str | None = None) -> bool: try: self.card.select() @@ -45,14 +49,18 @@ class KeycardWallet: raise RuntimeError("Card is already initialized") puk = ''.join(secrets.choice('0123456789') for _ in range(12)) - self.card.init(pin, puk, DEFAULT_PAIRING_PASSWORD) + self.card.init(pin, puk, pairing_password or _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: + def _reconnect(self) -> None: + self.card = KeyCard() + self.card.select() + + def _pair(self, pin: str, password: str) -> tuple[int, bytes]: self.card.select() if not self.card.is_initialized: @@ -70,14 +78,28 @@ class KeycardWallet: self.card.unpair(pairing_index) except Exception: pass - raise RuntimeError(f"Error setting up communication: {e}") from e + raise RuntimeError(f"Error opening secure channel after fresh pair: {e}") from e - return True + return pairing_index, pairing_key - def get_pairing_data(self) -> tuple[int, bytes]: - return (self.pairing_index, self.pairing_key) + def pair(self, pin: str, password: str | None = None) -> tuple[int, bytes]: + password = password or _pairing_password() + try: + return self._pair(pin, password) + except TransportError as e: + print(f"Transport error during fresh pair ({e}), attempting card reset and retry...") + try: + self._reconnect() + result = self._pair(pin, password) + print("Retry succeeded after card reset.") + return result + except TransportError as e2: + raise RuntimeError( + "Card lost power and did not recover after reset. " + "Try reseating the card in the reader." + ) from e2 - def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: + def _setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: self.card.select() if not self.card.is_initialized: @@ -94,6 +116,22 @@ class KeycardWallet: return True + def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: + try: + return self._setup_communication_with_pairing(pin, pairing_index, pairing_key) + except TransportError as e: + print(f"Transport error during stored pairing ({e}), attempting card reset and retry...") + try: + self._reconnect() + result = self._setup_communication_with_pairing(pin, pairing_index, pairing_key) + print("Retry succeeded after card reset.") + return result + except TransportError as e2: + raise RuntimeError( + "Card lost power and did not recover after reset. " + "Try reseating the card in the reader." + ) from e2 + def close_session(self) -> bool: return True diff --git a/keycard_wallet/src/lib.rs b/keycard_wallet/src/lib.rs index 9d75b575..c20fb663 100644 --- a/keycard_wallet/src/lib.rs +++ b/keycard_wallet/src/lib.rs @@ -56,10 +56,10 @@ impl KeycardWallet { .extract() } - pub fn get_pairing_data(&self, py: Python<'_>) -> PyResult<(u8, Vec)> { + pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec)> { self.instance .bind(py) - .call_method0("get_pairing_data")? + .call_method1("pair", (pin,))? .extract() } @@ -96,20 +96,11 @@ impl KeycardWallet { { return Ok(()); } - self.setup_communication(py, pin)?; - if let Ok((index, key)) = self.get_pairing_data(py) { - save_pairing(&KeycardPairingData { index, key }); - } + let (index, key) = self.pair(py, pin)?; + 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() } @@ -269,19 +260,9 @@ impl KeycardWallet { ) -> PyResult { Python::with_gil(|py| { python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - - let is_connected = wallet.setup_communication(py, pin)?; - - if is_connected { - log::info!("\u{2705} Keycard is now connected to wallet."); - } else { - log::info!("\u{274c} Keycard is not connected to wallet."); - } - + wallet.connect(py, pin)?; let result = wallet.get_private_keys_for_path(py, path); - drop(wallet.disconnect(py)); result }) diff --git a/keycard_wallet/tests/force_unpower.py b/keycard_wallet/tests/force_unpower.py new file mode 100755 index 00000000..427d2028 --- /dev/null +++ b/keycard_wallet/tests/force_unpower.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Forces the card in the first available reader into the unpowered state via +PC/SC SCARD_UNPOWER_CARD. Run immediately before a wallet command to simulate +the power-loss condition reported on some USB reader/driver combinations. + +Either: +- pcscd re-powers the card on the next SCardConnect, so wallet +commands will succeed without triggering the retry path. +- the card stays unpowered, triggering TransportError +and exercising the retry wrapper in pair() / setup_communication_with_pairing(). +""" +import sys +from smartcard.scard import ( + SCardEstablishContext, SCardListReaders, SCardConnect, SCardDisconnect, + SCARD_SCOPE_USER, SCARD_SHARE_SHARED, + SCARD_PROTOCOL_T0, SCARD_PROTOCOL_T1, + SCARD_UNPOWER_CARD, +) + +hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER) +hresult, reader_list = SCardListReaders(hcontext, []) + +if not reader_list: + print("force_unpower: no readers found, skipping.") + sys.exit(0) + +hresult, hcard, _ = SCardConnect( + hcontext, + reader_list[0], + SCARD_SHARE_SHARED, + SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1, +) + +if hresult != 0: + print(f"force_unpower: SCardConnect failed (hresult={hresult:#010x}), skipping.") + sys.exit(0) + +SCardDisconnect(hcard, SCARD_UNPOWER_CARD) +print("force_unpower: card powered down.") diff --git a/keycard_wallet/tests/keycard_power_recovery_tests.sh b/keycard_wallet/tests/keycard_power_recovery_tests.sh new file mode 100755 index 00000000..9ad23653 --- /dev/null +++ b/keycard_wallet/tests/keycard_power_recovery_tests.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# Power-recovery variant of keycard_tests.sh. +# +# Forces a card power cycle before each keycard-backed wallet command to verify +# commands survive mid-session power loss. +# +# On Linux: pcscd re-powers the card automatically on the next connection, so +# commands will succeed without triggering the retry path. This tests resilience +# to power cycles, not the retry wrapper itself. +# +# On Mac (affected driver): the card stays unpowered after force_unpower.py, +# triggering TransportError. You should see "Retry succeeded after card reset." +# in the output of each command, confirming the retry wrapper fires and recovers. +# +# Prerequisites: +# - Run wallet_with_keycard.sh first to set up the venv and dependencies. +# - Run `wallet keycard init` and `wallet keycard connect` before this script. + +source venv/bin/activate + +export KEYCARD_PIN=111111 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +unpower() { + python "$SCRIPT_DIR/force_unpower.py" +} + +echo "Test: wallet keycard available" +wallet keycard available + +echo "" +echo "Test: wallet keycard load (after power cycle)" +export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then" +unpower +wallet keycard load +unset KEYCARD_MNEMONIC + +echo "" +echo "Test: wallet auth-transfer init --account-id \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet auth-transfer init --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet pinata claim --to \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet pinata claim --to "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet auth-transfer init and send between two keycard accounts (after power cycle)" +unpower +wallet auth-transfer init --account-id "m/44'/60'/0'/0/1" +unpower +wallet auth-transfer send --amount 40 --from "m/44'/60'/0'/0/0" --to "m/44'/60'/0'/0/1" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/1" + +echo "" +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 "" +echo "Test: wallet auth-transfer init local account" +wallet auth-transfer init --account-id "Public/${LOCAL_ACCOUNT_ID}" + +echo "" +echo "Test: wallet auth-transfer send from keycard to local account (after power cycle)" +unpower +wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/${LOCAL_ACCOUNT_ID}" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\" (after power cycle)" +unpower +wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}" + +echo "" +echo "Test: wallet auth-transfer send from local account to keycard account (after power cycle)" +unpower +wallet auth-transfer send --amount 10 --from "Public/${LOCAL_ACCOUNT_ID}" --to "m/44'/60'/0'/0/1" + +echo "" +echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\" (after power cycle)" +unpower +wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/1" + +echo "" +echo "Test: wallet auth-transfer send from keycard to foreign account (after power cycle)" +wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" +unpower +wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" + +echo "" +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\" (after power cycle)" +unpower +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "Test: wallet account get foreign account (after power cycle)" +unpower +wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" diff --git a/keycard_test_3.sh b/keycard_wallet/tests/keycard_test_3.sh old mode 100644 new mode 100755 similarity index 100% rename from keycard_test_3.sh rename to keycard_wallet/tests/keycard_test_3.sh diff --git a/keycard_wallet/tests/keycard_tests.sh b/keycard_wallet/tests/keycard_tests.sh index e5ac2f2c..a2342d9c 100755 --- a/keycard_wallet/tests/keycard_tests.sh +++ b/keycard_wallet/tests/keycard_tests.sh @@ -28,7 +28,8 @@ 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" +echo "" +echo "=== Test: Keycard account to Keycard account ===" 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" @@ -38,7 +39,8 @@ 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 "" +echo "=== Test: Keycard account to public local 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}" @@ -56,7 +58,8 @@ 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 "" +echo "=== Test: public local account to Keycard account ===" 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" @@ -67,7 +70,8 @@ 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 "" +echo "=== Test: Keycard account to foreign recipient (no signature required) ===" echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" @@ -79,3 +83,44 @@ 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" + +echo "" +echo "=== Test: Shielded auth-transfer to owned private account ===" + +wallet auth-transfer send --amount 2 \ + --from "m/44'/60'/0'/0/0" \ + --to-npk "55204e2934045b044f06d8222b454d46b54788f33c7dec4f6733d441703bb0e6" \ + --to-vpk "02a8626b0c0ad9383c5678dad48c3969b4174fb377cdb03a6259648032c774cec8" +echo "Shielded auth-transfer sent" + +sleep 15 +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "" +echo "=== Test: Deshielded auth-transfer: private account → keycard path 1 ===" + +PRIV_SENDER=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1) +echo "Fresh private sender account: $PRIV_SENDER" + +wallet auth-transfer init --account-id "$PRIV_SENDER" + +echo "Test: wallet pinata claim to private sender" +wallet pinata claim --to "$PRIV_SENDER" + +sleep 15 + +echo "priv-sender state after claim:" +wallet account get --account-id "$PRIV_SENDER" + +wallet auth-transfer send \ + --from "$PRIV_SENDER" \ + --to "m/44'/60'/0'/0/1" \ + --amount 5 +echo "Deshielded transfer of 5: $PRIV_SENDER → keycard path 1" + +sleep 15 + +echo "priv-sender state (balance should have decreased by 5):" +wallet account get --account-id "$PRIV_SENDER" +echo "Keycard path 1 state (balance should have increased by 5):" +wallet account get --account-id "m/44'/60'/0'/0/1" \ No newline at end of file diff --git a/keycard_tests_2.sh b/keycard_wallet/tests/keycard_tests_2.sh old mode 100644 new mode 100755 similarity index 100% rename from keycard_tests_2.sh rename to keycard_wallet/tests/keycard_tests_2.sh diff --git a/wallet/src/cli/keycard.rs b/wallet/src/cli/keycard.rs index 4b666f6f..b01e83d0 100644 --- a/wallet/src/cli/keycard.rs +++ b/wallet/src/cli/keycard.rs @@ -40,7 +40,7 @@ impl WalletSubcommand for KeycardSubcommand { match self { Self::Available => { Python::with_gil(|py| { - python_path::add_python_path(py).expect("keycard_wallet.py not found"); + python_path::add_python_path(py).expect("`wallet::keycard::available`: unable to setup python path"); let wallet = KeycardWallet::new(py) .expect("`wallet::keycard::available`: invalid data received for pin"); @@ -61,7 +61,7 @@ impl WalletSubcommand for KeycardSubcommand { let pin = read_pin()?; Python::with_gil(|py| { - python_path::add_python_path(py).expect("keycard_wallet.py not found"); + python_path::add_python_path(py).expect("`wallet::keycard::connect`: unable to setup python path"); let wallet = KeycardWallet::new(py) .expect("`wallet::keycard::connect`: invalid keycard wallet provided"); @@ -80,7 +80,7 @@ impl WalletSubcommand for KeycardSubcommand { let pin = read_pin()?; Python::with_gil(|py| { - python_path::add_python_path(py).expect("keycard_wallet.py not found"); + python_path::add_python_path(py).expect("`wallet::keycard::disconnect`: unable to setup python path"); let wallet = KeycardWallet::new(py) .expect("`wallet::keycard::disconnect`: invalid keycard wallet provided"); @@ -103,7 +103,7 @@ impl WalletSubcommand for KeycardSubcommand { let pin = read_pin()?; Python::with_gil(|py| { - python_path::add_python_path(py).expect("keycard_wallet.py not found"); + python_path::add_python_path(py).expect("`wallet::keycard::init`: unable to setup python path"); let wallet = KeycardWallet::new(py) .expect("`wallet::keycard::init`: invalid keycard wallet provided"); @@ -125,7 +125,7 @@ impl WalletSubcommand for KeycardSubcommand { let mnemonic = read_mnemonic()?; Python::with_gil(|py| { - python_path::add_python_path(py).expect("keycard_wallet.py not found"); + python_path::add_python_path(py).expect("`wallet::keycard::load`: unable to setup python path"); let wallet = KeycardWallet::new(py) .expect("`wallet::keycard::load`: invalid keycard wallet provided"); diff --git a/wallet/src/cli/programs/token.rs b/wallet/src/cli/programs/token.rs index 77dae3b5..6137812f 100644 --- a/wallet/src/cli/programs/token.rs +++ b/wallet/src/cli/programs/token.rs @@ -199,7 +199,7 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { TokenProgramSubcommand::Public( TokenProgramSubcommandPublic::TransferToken { sender_account_id: from_mention, - recipient_account_id: to_mention.expect("matched Some branch"), + recipient_account_id: to_mention.expect("`wallet::cli::programs::token::Send`: Invalid to_mention account provided"), balance_to_move: amount, }, ) @@ -318,7 +318,7 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { amount, } => { let def_mention = definition.clone(); - let hol_mention = holder.clone(); + let holder_mention = holder.clone(); let definition = definition.resolve(wallet_core.storage())?; let holder = holder .map(|account_mention| account_mention.resolve(wallet_core.storage())) @@ -342,7 +342,7 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { TokenProgramSubcommand::Public( TokenProgramSubcommandPublic::MintToken { definition_account_id: def_mention, - holder_account_id: hol_mention.expect("matched Some branch"), + holder_account_id: holder_mention.expect("`wallet::cli::programs::token::Mint`: Invalid holder_mention account provided"), amount, }, ) diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 36fd1684..c1e018d6 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -40,7 +40,7 @@ use crate::{ cli::CliAccountMention, config::WalletConfigOverrides, poller::TxPoller, - signing::SigningGroups, + signing::SigningGroup, storage::key_chain::SharedAccountEntry, }; @@ -89,7 +89,7 @@ pub enum ExecutionFailureKind { } impl ExecutionFailureKind { - /// Convert an [`anyhow::Error`] (e.g. from [`SigningGroups`]) into a keycard error. + /// Convert an [`anyhow::Error`] (e.g. from [`SigningGroup`]) into a keycard error. #[must_use] #[expect( clippy::needless_pass_by_value, @@ -565,13 +565,13 @@ impl WalletCore { } /// Send a public transaction, fetching nonces automatically from - /// [`SigningGroups::signing_ids`]. + /// [`SigningGroup::signing_ids`]. pub async fn send_public_tx( &self, program: &Program, account_ids: Vec, instruction: T, - groups: SigningGroups, + groups: SigningGroup, ) -> Result { let nonces = self .get_accounts_nonces(groups.signing_ids()) @@ -591,7 +591,7 @@ impl WalletCore { account_ids: Vec, nonces: Vec, instruction: T, - groups: SigningGroups, + groups: SigningGroup, ) -> Result { let message = nssa::public_transaction::Message::try_new( program.id(), @@ -661,14 +661,14 @@ impl WalletCore { KeycardWallet::get_public_account_id_for_path_with_connect(&pin, key_path_str)?; let account_id: AccountId = match account_id_str .parse::() - .expect("Valid parsing of account id") + .expect("`wallet::lib::send_privacy_preserving_tx_with_pre_check`: invalid account id parsed") { AccountIdWithPrivacy::Public(id) | AccountIdWithPrivacy::Private(id) => id, }; let account = self .get_account_public(account_id) .await - .expect("Expect valid account"); + .expect("`wallet::lib::send_privacy_preserving_tx_with_pre_check`: unable to retrieve public account"); let pin_str = pin.as_str().to_owned(); ( Some(AccountWithMetadata { diff --git a/wallet/src/program_facades/amm.rs b/wallet/src/program_facades/amm.rs index 526a35b5..0a3fef32 100644 --- a/wallet/src/program_facades/amm.rs +++ b/wallet/src/program_facades/amm.rs @@ -3,7 +3,7 @@ use common::HashType; use nssa::{AccountId, program::Program}; use token_core::TokenHolding; -use crate::{ExecutionFailureKind, WalletCore, cli::CliAccountMention, signing::SigningGroups}; +use crate::{ExecutionFailureKind, WalletCore, cli::CliAccountMention, signing::SigningGroup}; pub struct Amm<'wallet>(pub &'wallet WalletCore); impl Amm<'_> { @@ -61,7 +61,7 @@ impl Amm<'_> { user_holding_lp, ]; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(a_mention, user_holding_a, self.0) .and_then(|()| groups.add_required(b_mention, user_holding_b, self.0)) @@ -133,7 +133,7 @@ impl Amm<'_> { )); }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(seller_mention, account_id_auth, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; @@ -202,7 +202,7 @@ impl Amm<'_> { )); }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(seller_mention, account_id_auth, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; @@ -266,7 +266,7 @@ impl Amm<'_> { user_holding_lp, ]; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(a_mention, user_holding_a, self.0) .and_then(|()| groups.add_required(b_mention, user_holding_b, self.0)) @@ -331,7 +331,7 @@ impl Amm<'_> { user_holding_lp, ]; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(lp_mention, user_holding_lp, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; diff --git a/wallet/src/program_facades/ata.rs b/wallet/src/program_facades/ata.rs index 9b38ca2f..09f11eea 100644 --- a/wallet/src/program_facades/ata.rs +++ b/wallet/src/program_facades/ata.rs @@ -9,7 +9,7 @@ use nssa_core::SharedSecretKey; use crate::{ ExecutionFailureKind, PrivacyPreservingAccount, WalletCore, cli::CliAccountMention, - signing::SigningGroups, + signing::SigningGroup, }; pub struct Ata<'wallet>(pub &'wallet WalletCore); @@ -31,7 +31,7 @@ impl Ata<'_> { let account_ids = vec![owner_id, definition_id, ata_id]; let instruction = ata_core::Instruction::Create { ata_program_id }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(owner_mention, owner_id, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; @@ -61,7 +61,7 @@ impl Ata<'_> { amount, }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(owner_mention, owner_id, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; @@ -90,7 +90,7 @@ impl Ata<'_> { amount, }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(owner_mention, owner_id, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; diff --git a/wallet/src/program_facades/native_token_transfer/public.rs b/wallet/src/program_facades/native_token_transfer/public.rs index b33104cc..68c8e0be 100644 --- a/wallet/src/program_facades/native_token_transfer/public.rs +++ b/wallet/src/program_facades/native_token_transfer/public.rs @@ -3,7 +3,7 @@ use common::HashType; use nssa::{AccountId, program::Program}; use super::NativeTokenTransfer; -use crate::{ExecutionFailureKind, cli::CliAccountMention, signing::SigningGroups}; +use crate::{ExecutionFailureKind, cli::CliAccountMention, signing::SigningGroup}; impl NativeTokenTransfer<'_> { pub async fn send_public_transfer( @@ -14,7 +14,7 @@ impl NativeTokenTransfer<'_> { from_mention: &CliAccountMention, to_mention: &CliAccountMention, ) -> Result { - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(from_mention, from, self.0) .and_then(|()| groups.add_optional(to_mention, to, self.0)) @@ -37,7 +37,7 @@ impl NativeTokenTransfer<'_> { from: AccountId, account_mention: &CliAccountMention, ) -> Result { - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(account_mention, from, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; diff --git a/wallet/src/program_facades/token.rs b/wallet/src/program_facades/token.rs index ba6779a9..c16b6365 100644 --- a/wallet/src/program_facades/token.rs +++ b/wallet/src/program_facades/token.rs @@ -5,7 +5,7 @@ use token_core::Instruction; use crate::{ ExecutionFailureKind, PrivacyPreservingAccount, WalletCore, cli::CliAccountMention, - signing::SigningGroups, + signing::SigningGroup, }; pub struct Token<'wallet>(pub &'wallet WalletCore); @@ -23,7 +23,7 @@ impl Token<'_> { let account_ids = vec![definition_account_id, supply_account_id]; let instruction = Instruction::NewFungibleDefinition { name, total_supply }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(definition_mention, definition_account_id, self.0) .and_then(|()| groups.add_required(supply_mention, supply_account_id, self.0)) @@ -147,7 +147,7 @@ impl Token<'_> { amount_to_transfer: amount, }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(sender_mention, sender_account_id, self.0) .and_then(|()| groups.add_optional(recipient_mention, recipient_account_id, self.0)) @@ -350,7 +350,7 @@ impl Token<'_> { amount_to_burn: amount, }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(holder_mention, holder_account_id, self.0) .map_err(ExecutionFailureKind::from_anyhow)?; @@ -476,7 +476,7 @@ impl Token<'_> { amount_to_mint: amount, }; - let mut groups = SigningGroups::new(); + let mut groups = SigningGroup::new(); groups .add_required(definition_mention, definition_account_id, self.0) .and_then(|()| groups.add_optional(holder_mention, holder_account_id, self.0)) diff --git a/wallet/src/signing.rs b/wallet/src/signing.rs index cd5e865d..47dd9ec1 100644 --- a/wallet/src/signing.rs +++ b/wallet/src/signing.rs @@ -10,12 +10,12 @@ use crate::{WalletCore, cli::CliAccountMention}; /// 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 SigningGroups { +pub struct SigningGroup { local: Vec<(AccountId, PrivateKey)>, keycard: Vec<(AccountId, String)>, } -impl SigningGroups { +impl SigningGroup { #[must_use] pub fn new() -> Self { Self::default() diff --git a/wallet_with_keycard.sh b/wallet_with_keycard.sh deleted file mode 100644 index bcd87bd3..00000000 --- a/wallet_with_keycard.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/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