From feb6cb7f92d9926411b1aa00486fe198bd05bf13 Mon Sep 17 00:00:00 2001 From: jonesmarvin8 <83104039+jonesmarvin8@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:35:10 -0400 Subject: [PATCH] feat(wallet): add keycard support for public accounts for public/privacy txs for program facades (#461) * feat: add basic commands for communicating with keycard * initialize changes * reorganization * add script file for easier wallet access * update commands * fixes * fixed load for non continuous run * Updates for signatures with keycard * fix BIP-340 signatures for fixed sized messages * fmt * refactor and add pin support to program facades * fix unit test * fixes * Revert "fixes" This reverts commit 41f34f4ff4145b7abb60fd9bec168ae4b60f23b4. * fixes * fixes * Removed privacy keycard calls * Revert "Removed privacy keycard calls" This reverts commit d70ef505a1f40b87159099761f5fce5a31e3f17b. * Add domain separators * Removed privacy txs for keycard * CI fixes * CI fixes * addressed some comments * fix ci * initialize branch * ci fixes * fix integration test issue and updated keycard firmware * addressed more comments * fixed deny * remove keycard-py * fixed from earlier merge * add hash_message tests * add test * fix deny * CI fixes * fixed integration tests * Update public.rs * update artifacts * privacy command fixes * ci and comments * addressed comments * comment fixes * fixes from merging main * adding support to other programs * expanded support * ci fixes * ci and add private account keys test * some fixes and setup notes * Ci fixes * ci fixes * update key paths to avoid collisions in tests * added separated files for keycard_tests_2.sh * first round of comments * Revert "Merge branch 'main' into marvin/keycard-commands" This reverts commit 3fce53f663a3996938dddf77680854570063ca21, reversing changes made to e7b42a5177641455a8917bd2e29db20afd9690e5. * python comments * addressed comments * compile error fixed * fix artifacts * fix main merge error * adjust signer logic workflow * updating logic * fmt * refactored * clippy fix * minor fix * addressing comments * minor fix * ci fix * addressed deferred comments * clean up * minor cleanup * ci fixes * fmt fix * feat!(wallet): Merged `SigningGroup` with `AccountManager` (#500) * feat: account manager extension * feat(wallet): added unified way of sending public transactions to all facades * fix(wallet): no sign option added * fix(deny): deny fix * fix(wallet): suggestion 1 * fix(wallet): suggestion fix 1 * feat!: Add new path for externally provided seed to the circuit. BREAKING CHANGE: add identity variants to the circuit and change semantics for `Claim::Authorized` for private PDAs * feat(ci): use separate job per each integration tests module * feat(ci): cache rust artifacts * feat(ci): build integration tests binary once and reuse it * fix(wallet): fmt * ci: add bench-regression workflow with criterion-compare for crypto_primitives_bench * fix(wallet): merge postfix * feat!(wallet): SigningGroup merged with AccountManager * fix(ci): deny and artifacts fix * fix(deny): deny fix * fix keycard and lint --------- Co-authored-by: Sergio Chouhy Co-authored-by: Daniil Polyakov Co-authored-by: Moudy Co-authored-by: Sergio Chouhy <41742639+schouhy@users.noreply.github.com> Co-authored-by: jonesmarvin8 <83104039+jonesmarvin8@users.noreply.github.com> * addressed comments * minor comments * Rebase to main * CI fixes --------- Co-authored-by: Pravdyvy <46261001+Pravdyvy@users.noreply.github.com> Co-authored-by: Sergio Chouhy Co-authored-by: Daniil Polyakov Co-authored-by: Moudy Co-authored-by: Sergio Chouhy <41742639+schouhy@users.noreply.github.com> --- .deny.toml | 1 + .gitignore | 3 + Cargo.lock | 1 + Cargo.toml | 3 +- docs/LEZ testnet v0.1 tutorials/keycard.md | 319 +++++++++++- integration_tests/tests/wallet_ffi.rs | 2 + lez/keycard_wallet/Cargo.toml | 1 + lez/keycard_wallet/python/keycard_wallet.py | 79 ++- lez/keycard_wallet/src/lib.rs | 99 +++- lez/keycard_wallet/src/python_path.rs | 6 +- lez/keycard_wallet/tests/force_unpower.py | 40 ++ .../tests/keycard_power_recovery_tests.sh | 117 +++++ lez/keycard_wallet/tests/keycard_test_3.sh | 21 + lez/keycard_wallet/tests/keycard_tests.sh | 59 ++- lez/keycard_wallet/tests/keycard_tests_2.sh | 457 ++++++++++++++++++ lez/wallet-ffi/src/transfer.rs | 50 +- lez/wallet-ffi/wallet_ffi.h | 2 + lez/wallet/Cargo.toml | 7 +- lez/wallet/src/account_manager.rs | 140 +++++- lez/wallet/src/cli/account.rs | 14 + lez/wallet/src/cli/keycard.rs | 50 +- lez/wallet/src/cli/mod.rs | 25 +- lez/wallet/src/cli/programs/amm.rs | 125 ++--- lez/wallet/src/cli/programs/ata.rs | 45 +- .../src/cli/programs/native_token_transfer.rs | 60 ++- lez/wallet/src/cli/programs/token.rs | 181 ++++--- lez/wallet/src/lib.rs | 37 +- lez/wallet/src/program_facades/amm.rs | 112 +++-- lez/wallet/src/program_facades/ata.rs | 24 +- .../native_token_transfer/public.rs | 130 +---- .../native_token_transfer/shielded.rs | 10 +- lez/wallet/src/program_facades/token.rs | 56 +-- lez/wallet/src/signing.rs | 125 +---- 33 files changed, 1833 insertions(+), 568 deletions(-) create mode 100755 lez/keycard_wallet/tests/force_unpower.py create mode 100755 lez/keycard_wallet/tests/keycard_power_recovery_tests.sh create mode 100755 lez/keycard_wallet/tests/keycard_test_3.sh create mode 100755 lez/keycard_wallet/tests/keycard_tests_2.sh diff --git a/.deny.toml b/.deny.toml index 21d40007..00e800c5 100644 --- a/.deny.toml +++ b/.deny.toml @@ -15,6 +15,7 @@ ignore = [ { id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is pulled transitively via risc0-zkvm; waiting on upstream fix (see https://github.com/risc0/risc0/issues/3453)" }, { id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, { id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, + { id = "RUSTSEC-2024-0370", reason = "transitive dependency of `logos-blockchain-http-api-common`, can't do anything than wait for upstream fix" }, ] yanked = "deny" 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/Cargo.lock b/Cargo.lock index d5ce6321..e0c8ea2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4617,6 +4617,7 @@ dependencies = [ "pyo3", "serde", "serde_json", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d3e38fcc..290c3540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,7 +137,6 @@ url = { version = "2.5.4", features = ["serde"] } tokio-retry = "0.3.0" schemars = "1.2" async-stream = "0.3.6" -criterion = { version = "0.8", features = ["html_reports"] } logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" } logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "db9a8d821c1b20f29b03d02072817150cf969b8e" } @@ -167,6 +166,8 @@ 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"] } +zeroize = "1" +criterion = { version = "0.8", features = ["html_reports"] } # Profile for leptos WASM release builds [profile.wasm-release] diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md index 81b154e3..47573f12 100644 --- a/docs/LEZ testnet v0.1 tutorials/keycard.md +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -60,17 +60,44 @@ 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 +# Note: Keep the leading space before this command. +# Leading space prevents this command from being stored in shell history +# (when HISTCONTROL=ignorespace is enabled). + 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 -| 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 | +| 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 | +| `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path — **debug builds only** (see below) | 1. Check keycard availability ```bash @@ -122,6 +149,31 @@ Keycard PIN: ✅ Keycard unpaired and pairing cleared. ``` +6. Get private keys for a BIP-32 path (**debug builds only**) + +`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. The command is only available in debug builds and requires `--reveal` to confirm intent. + +First install the wallet with the `keycard-debug` feature: +```bash +cargo install --path lez/wallet --force --features keycard-debug +``` + +Then run the command: +```bash +wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal + +# Output: +WARNING: NSK and VSK are being printed to stdout. Any terminal log, scrollback, or screen recording captures these keys. +Keycard PIN: +NSK: 55e505bf925e536c843a12ebc08c41ca5f4761eeeb7fa33725f0b44e6f1ac2e4 +VSK: 30f798893977a7b7263d1f77abf58e11e014428c92030d6a02fe363cceb41ffa +``` + +To restore the standard build without `keycard-debug` afterwards: +```bash +cargo install --path lez/wallet --force +``` + ### Pinata (testnet) | Command | Description | @@ -213,25 +265,270 @@ Keycard PIN: Transaction hash is 7d4c1b8e2f903a56fd19084b3c8b25d07e8f243829bc50addf6e2c78b4b09e45 ``` +### Token program + +`--definition`, `--holder`, `--from`, and `--to` each accept any of: +- A BIP-32 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`) + +The token program requires both the definition account and the holder/recipient to sign when both are owned. If only one is a Keycard path, only that account signs via the card; the other signs locally or is treated as foreign. + +**Shielded transfers** (public Keycard sender → private recipient) are supported. The Keycard signs the public sender's authorization; the ZK circuit handles the private recipient side. + +| Command | Description | +|--------------------|-------------------------------------------------------| +| `wallet token new` | Creates a new token definition with an initial supply | +| `wallet token send`| Transfers tokens between accounts | +| `wallet token mint`| Mints tokens to a holder account | +| `wallet token burn`| Burns tokens from a holder account | + +1. Create a new token — definition and supply both on Keycard +```bash +wallet token new \ + --definition-account-id "m/44'/60'/0'/0/2" \ + --supply-account-id "m/44'/60'/0'/0/3" \ + --name LEZ \ + --total-supply 100000 + +# Output: +Keycard PIN: +Transaction hash is a3f1c8e2049b7d56fe19084b3c8b25d07e8f243829bc50addf6e2c78b4b09d11 +Transaction data is ... +``` + +2. Transfer tokens between two Keycard accounts (public → public) +```bash +wallet token send \ + --from "m/44'/60'/0'/0/3" \ + --to "m/44'/60'/0'/0/6" \ + --amount 20000 + +# Output: +Keycard PIN: +Transaction hash is b2e4d9f1038c6e45ad28175c4d9c36e18bf9354930cd61beef59f3e89c5a0e22 +Transaction data is ... +``` + +3. Transfer tokens from a Keycard account to a private account (shielded) +```bash +wallet token send \ + --from "m/44'/60'/0'/0/6" \ + --to "Private/CJwKfrb3DFMmFvujQSB5ARcRTAa8EdP6eWm2hmSkF7Rb" \ + --amount 500 + +# Output: +Keycard PIN: +Transaction hash is c5f7e0a2149d8f67be39286d5eaa47f29cg0465041de72cff06a4f9ad6b1f33 +``` + +4. Mint tokens — Keycard definition account mints to a Keycard holder +```bash +wallet token mint \ + --definition "m/44'/60'/0'/0/2" \ + --holder "m/44'/60'/0'/0/6" \ + --amount 2000 + +# Output: +Keycard PIN: +Transaction hash is d6g8f1b3250e9a78cf4a397e6fbb58g3ah1567152ef83dgg17b5g0be7c2g0g44 +Transaction data is ... +``` + +5. Burn tokens — Keycard holder burns from its own account +```bash +wallet token burn \ + --definition "Public/9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \ + --holder "m/44'/60'/0'/0/6" \ + --amount 500 + +# Output: +Keycard PIN: +Transaction hash is e7h9g2c4361f0b89dg5b408f7gcc69h4bi2678263fg94ehh28c6h1cf8d3h1h55 +Transaction data is ... +``` + +### AMM program + +AMM operations are **public only** — all holdings involved must be public accounts. Keycard accounts can be used for any or all of the holding accounts. + +`--user-holding-a`, `--user-holding-b`, and `--user-holding-lp` each accept any of: +- A BIP-32 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 swaps, only the seller's holding signs — the wallet identifies which holding corresponds to the input token and signs only that account. + +| Command | Description | +|----------------------------|-------------------------------------------------------| +| `wallet amm new` | Creates a new AMM liquidity pool | +| `wallet amm swap-exact-input` | Swaps specifying exact input amount | +| `wallet amm swap-exact-output` | Swaps specifying exact output amount | +| `wallet amm add-liquidity` | Adds liquidity to an existing pool | +| `wallet amm remove-liquidity` | Removes liquidity from a pool | + +1. Create a new AMM pool — all holdings on Keycard +```bash +wallet amm new \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --user-holding-lp "m/44'/60'/0'/0/8" \ + --balance-a 10000 \ + --balance-b 10000 + +# Output: +Keycard PIN: +Transaction hash is f8i0h3d5472g1c90eh6c519g8hdd70i5cj3789374gh05fii39d7i2dg9e4i2i66 +Transaction data is ... +``` + +2. Swap exact input — Keycard account sells LEE, receives LEZ +```bash +wallet amm swap-exact-input \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --amount-in 500 \ + --min-amount-out 1 \ + --token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" + +# Output: +Keycard PIN: +Transaction hash is g9j1i4e6583h2d01fi7d620h9iee81j6dk4890485hi16gjj40e8j3eh0f5j3j77 +Transaction data is ... +``` + +3. Add liquidity — all three holdings on Keycard +```bash +wallet amm add-liquidity \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --user-holding-lp "m/44'/60'/0'/0/8" \ + --max-amount-a 1000 \ + --max-amount-b 1000 \ + --min-amount-lp 1 + +# Output: +Keycard PIN: +Transaction hash is h0k2j5f7694i3e12gj8e731i0jff92k7el5901596ij27hkk51f9k4fi1g6k4k88 +Transaction data is ... +``` + +4. Remove liquidity — LP holding on Keycard +```bash +wallet amm remove-liquidity \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --user-holding-lp "m/44'/60'/0'/0/8" \ + --balance-lp 500 \ + --min-amount-a 1 \ + --min-amount-b 1 + +# Output: +Keycard PIN: +Transaction hash is i1l3k6g8705j4f23hk9f842j1kgg03l8fm6012607jk38ill62g0l5gj2h7l5l99 +Transaction data is ... +``` + +### ATA program + +The Associated Token Account program derives a deterministic token holding address from an owner account and a token definition. Keycard accounts can be used as the owner. + +`--owner` and `--from`/`--holder` accept any of: +- A BIP-32 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`) + +| Command | Description | +|--------------------|------------------------------------------------------------------| +| `wallet ata address` | Derives and prints the ATA address (local only, no network) | +| `wallet ata create` | Creates the ATA on-chain | +| `wallet ata send` | Sends tokens from the owner's ATA to a recipient | +| `wallet ata burn` | Burns tokens from the owner's ATA | +| `wallet ata list` | Lists ATAs for a given owner across token definitions | + +1. Derive an ATA address for a Keycard account +```bash +# First resolve the Keycard account ID +OWNER_ID=$(wallet account id --account-id "m/44'/60'/0'/0/9") +wallet ata address \ + --owner "$OWNER_ID" \ + --token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" + +# Output: +DFMmFvujQSB5ARcRTAa8EdP6eWm2hmSkF7RbCJwKfrb3 +``` + +2. Create an ATA — Keycard account as owner +```bash +wallet ata create \ + --owner "m/44'/60'/0'/0/9" \ + --token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" + +# Output: +Keycard PIN: +Transaction hash is j2m4l7h9816k5g34il0g953k2lhh14m9gn7123718kl49jmm73h1m6hk3i8m6m00 +Transaction data is ... +``` + +3. Send tokens from a Keycard ATA to another account +```bash +wallet ata send \ + --from "m/44'/60'/0'/0/9" \ + --token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \ + --to "DFMmFvujQSB5ARcRTAa8EdP6eWm2hmSkF7RbCJwKfrb3" \ + --amount 500 + +# Output: +Keycard PIN: +Transaction hash is k3n5m8i0927l6h45jm1h064l3mii25n0ho8234829lm50knn84i2n7il4j9n7n11 +Transaction data is ... +``` + +4. Burn tokens from a Keycard ATA +```bash +wallet ata burn \ + --holder "m/44'/60'/0'/0/9" \ + --token-definition "9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \ + --amount 200 + +# Output: +Keycard PIN: +Transaction hash is l4o6n9j1038m7i56kn2i175m4njj36o1ip9345930mn61loo95j3o8jm5k0o8o22 +Transaction data is ... +``` + ## Testing -Tests for Keycard commands are in `lez/keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected: +Tests for Keycard commands are in `lez/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 lez/keycard_wallet/tests/keycard_tests.sh +bash lez/keycard_wallet/tests/keycard_tests_2.sh +bash lez/keycard_wallet/tests/keycard_test_3.sh +bash lez/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` (`lez/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 } +``` ``` \ No newline at end of file diff --git a/integration_tests/tests/wallet_ffi.rs b/integration_tests/tests/wallet_ffi.rs index 68408d78..70e6d44d 100644 --- a/integration_tests/tests/wallet_ffi.rs +++ b/integration_tests/tests/wallet_ffi.rs @@ -137,6 +137,7 @@ unsafe extern "C" { to_keys: *const FfiPrivateAccountKeys, to_identifier: *const FfiU128, amount: *const [u8; 16], + key_path: *const c_char, out_result: *mut FfiTransferResult, ) -> error::WalletFfiError; @@ -882,6 +883,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { &raw const to_keys, &raw const to_identifier, &raw const amount, + std::ptr::null(), &raw mut transfer_result, ) .unwrap(); diff --git a/lez/keycard_wallet/Cargo.toml b/lez/keycard_wallet/Cargo.toml index 746915cb..4abff1f1 100644 --- a/lez/keycard_wallet/Cargo.toml +++ b/lez/keycard_wallet/Cargo.toml @@ -13,3 +13,4 @@ pyo3.workspace = true log.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +zeroize.workspace = true diff --git a/lez/keycard_wallet/python/keycard_wallet.py b/lez/keycard_wallet/python/keycard_wallet.py index 7e18636a..21e966cb 100644 --- a/lez/keycard_wallet/python/keycard_wallet.py +++ b/lez/keycard_wallet/python/keycard_wallet.py @@ -3,15 +3,18 @@ from keycard.exceptions import APDUError, TransportError 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 -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 +40,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 +48,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 +77,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 +115,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 @@ -161,4 +198,24 @@ class KeycardWallet: return signature.signature except Exception as e: - raise RuntimeError(f"Error signing message: {e}") from e \ No newline at end of file + raise RuntimeError(f"Error signing message: {e}") from e + + def get_private_keys_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 + + private_keys = export_lee_key( + self.card, + constants.DerivationOption.DERIVE, + path + ) + + nsk = private_keys.lee_nsk + vsk = private_keys.lee_vsk + + return (nsk, vsk) + + except Exception as e: + raise RuntimeError(f"Error getting private keys: {e}") from e + diff --git a/lez/keycard_wallet/src/lib.rs b/lez/keycard_wallet/src/lib.rs index d7a04544..1f009900 100644 --- a/lez/keycard_wallet/src/lib.rs +++ b/lez/keycard_wallet/src/lib.rs @@ -3,9 +3,13 @@ use std::path::PathBuf; use lee::{AccountId, PublicKey, Signature}; use pyo3::{prelude::*, types::PyAny}; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; pub mod python_path; +/// NSK and VSK as fixed-length zeroizing byte arrays. +type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>); + // TODO: encrypt at rest alongside broader wallet storage encryption work. #[derive(Serialize, Deserialize)] pub struct KeycardPairingData { @@ -51,10 +55,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() } @@ -91,20 +95,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() } @@ -138,6 +133,10 @@ impl KeycardWallet { }) } + #[expect( + clippy::arithmetic_side_effects, + reason = "64 - s_stripped.len() is safe: s_stripped.len() ≤ 31 because py_signature.len() is in [32, 63]" + )] pub fn sign_message_for_path( &self, py: Python, @@ -150,6 +149,24 @@ impl KeycardWallet { .call_method1("sign_message_for_path", (message, path))? .extract()?; + // The keycard Python library strips leading zeros from S when S < 2^(8k) for some k. + // Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S). + let py_signature = if py_signature.len() < 64 { + if py_signature.len() < 32 { + return Err(PyErr::new::(format!( + "signature from keycard too short: {} bytes", + py_signature.len() + ))); + } + let s_stripped = &py_signature[32..]; + let mut padded = [0_u8; 64]; + padded[..32].copy_from_slice(&py_signature[..32]); + padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped); + padded.to_vec() + } else { + py_signature + }; + let signature: [u8; 64] = py_signature.try_into().map_err(|vec: Vec| { PyErr::new::(format!( "Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})", @@ -190,11 +207,65 @@ impl KeycardWallet { Ok(()) } - pub fn get_account_id_for_path_with_connect(pin: &str, key_path: &str) -> PyResult { + pub fn get_public_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))) } + + pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult { + let (raw_nsk, raw_vsk): (Vec, Vec) = self + .instance + .bind(py) + .call_method1("get_private_keys_for_path", (path,))? + .extract()?; + + let raw_nsk = Zeroizing::new(raw_nsk); + let raw_vsk = Zeroizing::new(raw_vsk); + + let nsk = { + if raw_nsk.len() != 32 { + return Err(PyErr::new::(format!( + "expected 32-byte NSK from keycard, got {} bytes", + raw_nsk.len() + ))); + } + let mut arr = Zeroizing::new([0_u8; 32]); + arr.copy_from_slice(&raw_nsk); + arr + }; + + let vsk = { + if raw_vsk.len() != 32 { + return Err(PyErr::new::(format!( + "expected 32-byte VSK from keycard, got {} bytes", + raw_vsk.len() + ))); + } + let mut arr = Zeroizing::new([0_u8; 32]); + arr.copy_from_slice(&raw_vsk); + arr + }; + + Ok((nsk, vsk)) + } + + pub fn get_private_keys_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 result = wallet.get_private_keys_for_path(py, path); + drop(wallet.disconnect(py)); + result + }) + } } fn pairing_file_path() -> Option { diff --git a/lez/keycard_wallet/src/python_path.rs b/lez/keycard_wallet/src/python_path.rs index 5261d7b7..cee051ca 100644 --- a/lez/keycard_wallet/src/python_path.rs +++ b/lez/keycard_wallet/src/python_path.rs @@ -12,8 +12,12 @@ pub fn add_python_path(py: Python<'_>) -> PyResult<()> { .unwrap_or_else(|| current_dir.clone()); let mut paths_to_add: Vec = vec![ - python_base.join("keycard_wallet").join("python"), python_base + .join("lez") + .join("keycard_wallet") + .join("python"), + python_base + .join("lez") .join("keycard_wallet") .join("python") .join("keycard-py"), diff --git a/lez/keycard_wallet/tests/force_unpower.py b/lez/keycard_wallet/tests/force_unpower.py new file mode 100755 index 00000000..427d2028 --- /dev/null +++ b/lez/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/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh new file mode 100755 index 00000000..3d8301f7 --- /dev/null +++ b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh @@ -0,0 +1,117 @@ +#!/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. + +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/lez/keycard_wallet/tests/keycard_test_3.sh b/lez/keycard_wallet/tests/keycard_test_3.sh new file mode 100755 index 00000000..d80e2aca --- /dev/null +++ b/lez/keycard_wallet/tests/keycard_test_3.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# keycard_test_3.sh — tests for `wallet keycard get-private-keys`. +# +# Prerequisites: +# 1. Run wallet_with_keycard.sh once to install dependencies. +# 2. Keycard reader inserted with card loaded (wallet keycard load has been run). + +source venv/bin/activate + +cargo install --path lez/wallet --force --features keycard-debug + +export KEYCARD_PIN=111111 + +echo "=== Test: wallet keycard get-private-keys path 10 ===" +wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal + +echo "=== Test: wallet keycard get-private-keys path 11 ===" +wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/11" --reveal + +echo "" +echo "=== All get-private-keys tests finished ===" diff --git a/lez/keycard_wallet/tests/keycard_tests.sh b/lez/keycard_wallet/tests/keycard_tests.sh index e5ac2f2c..dfa30461 100755 --- a/lez/keycard_wallet/tests/keycard_tests.sh +++ b/lez/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,50 @@ 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 ===" + +SHIELDED_RECV=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1) +echo "Private recipient: $SHIELDED_RECV" +SHIELDED_KEYS=$(wallet account show-keys --account-id "$SHIELDED_RECV") +SHIELDED_NPK=$(echo "$SHIELDED_KEYS" | head -1) +SHIELDED_VPK=$(echo "$SHIELDED_KEYS" | tail -1) + +wallet auth-transfer send --amount 2 \ + --from "m/44'/60'/0'/0/0" \ + --to-npk "$SHIELDED_NPK" \ + --to-vpk "$SHIELDED_VPK" +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" diff --git a/lez/keycard_wallet/tests/keycard_tests_2.sh b/lez/keycard_wallet/tests/keycard_tests_2.sh new file mode 100755 index 00000000..cbff19fe --- /dev/null +++ b/lez/keycard_wallet/tests/keycard_tests_2.sh @@ -0,0 +1,457 @@ +#!/usr/bin/env bash +# keycard_tests_2.sh — comprehensive token + AMM keycard integration tests. +# +# Prerequisites: +# 1. Run wallet_with_keycard.sh once to install dependencies. +# 2. Reset the local chain so all accounts are uninitialized. +# 3. Keycard reader inserted with card loaded. +# +# Keycard path layout: +# path 2 → LEZ token definition (keycard) +# path 3 → LEZ token supply (keycard) +# path 4 → LEE token definition (keycard) +# path 5 → LEE token supply (keycard) +# path 6 → LEZ holding (keycard — transfers, mint, burn, swap, liquidity) +# path 7 → LEE holding (keycard — swap, add/remove liquidity) +# path 8 → LP holding (keycard — add/remove liquidity) +# path 9 → ATA owner (keycard — ATA create, send, burn) +# +# Non-keycard accounts: +# pub-receiver → public account (target for keycard → public token transfer) +# priv-receiver → private account (target for keycard → private token transfer) +# amm-lez-fund → public LEZ holding used to seed the AMM pool +# amm-lee-fund → public LEE holding used to seed the AMM pool +# (LP holding for amm new is created fresh each run — no persistent label) + +source venv/bin/activate +export KEYCARD_PIN=111111 + +# ============================================================================= +# Keycard setup +# ============================================================================= +echo "" +echo "=== Keycard setup ===" +wallet keycard available +export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then" +wallet keycard load +unset KEYCARD_MNEMONIC + +# ============================================================================= +# Create non-keycard wallet accounts +# ============================================================================= +echo "" +echo "=== Create non-keycard accounts ===" +wallet account new public --label pub-receiver 2>/dev/null || true + +wallet account new public --label amm-lez-fund 2>/dev/null || true +wallet account new public --label amm-lee-fund 2>/dev/null || true +wallet account new public --label amm-lp-fund 2>/dev/null || true + +# ============================================================================= +# (1) Create LEZ token — definition AND supply via keycard paths +# ============================================================================= +echo "" +echo "=== (1) Create LEZ token (keycard def=path2, supply=path3) ===" +wallet token new \ + --definition-account-id "m/44'/60'/0'/0/2" \ + --supply-account-id "m/44'/60'/0'/0/3" \ + --name LEZ \ + --total-supply 100000 +echo "LEZ token created" + +# ============================================================================= +# (2) Create LEE token — definition AND supply via keycard paths +# ============================================================================= +echo "" +echo "=== (2) Create LEE token (keycard def=path4, supply=path5) ===" +wallet token new \ + --definition-account-id "m/44'/60'/0'/0/4" \ + --supply-account-id "m/44'/60'/0'/0/5" \ + --name LEE \ + --total-supply 100000 +echo "LEE token created" + +sleep 15 + +LEZ_DEF_ID=$(wallet account id --account-id "m/44'/60'/0'/0/2") +LEE_DEF_ID=$(wallet account id --account-id "m/44'/60'/0'/0/4") +echo "LEZ definition ID: $LEZ_DEF_ID" +echo "LEE definition ID: $LEE_DEF_ID" + +echo "Keycard path 2 (LEZ definition) state:" +wallet account get --account-id "m/44'/60'/0'/0/2" +echo "Keycard path 3 (LEZ supply) state:" +wallet account get --account-id "m/44'/60'/0'/0/3" +echo "Keycard path 4 (LEE definition) state:" +wallet account get --account-id "m/44'/60'/0'/0/4" +echo "Keycard path 5 (LEE supply) state:" +wallet account get --account-id "m/44'/60'/0'/0/5" + +# ============================================================================= +# Initialize token holding accounts +# ============================================================================= +echo "" +echo "=== Initialize token holding accounts ===" + +# Keycard path 6: LEZ holding (mint 0 to initialize) +wallet token mint \ + --definition "m/44'/60'/0'/0/2" \ + --holder "m/44'/60'/0'/0/6" \ + --amount 0 +echo "LEZ holding initialized for keycard path 6" + +# Keycard path 7: LEE holding (different definition — safe to submit immediately) +wallet token mint \ + --definition "m/44'/60'/0'/0/4" \ + --holder "m/44'/60'/0'/0/7" \ + --amount 0 +echo "LEE holding initialized for keycard path 7" + +# Wait for path2 (LEZ def) and path4 (LEE def) nonces to be confirmed before reusing them +sleep 15 + +# pub-receiver: public LEZ holding +wallet token mint \ + --definition "m/44'/60'/0'/0/2" \ + --holder pub-receiver \ + --amount 0 +echo "LEZ holding initialized for pub-receiver" + +# amm-lee-fund: LEE holding (different definition — safe to submit with pub-receiver) +wallet token mint \ + --definition "m/44'/60'/0'/0/4" \ + --holder amm-lee-fund \ + --amount 0 +echo "LEE holding initialized for amm-lee-fund" + +# Wait for path2 nonce to be confirmed before the third LEZ mint +sleep 15 + +# amm-lez-fund: LEZ holding +wallet token mint \ + --definition "m/44'/60'/0'/0/2" \ + --holder amm-lez-fund \ + --amount 0 +echo "AMM seed holdings initialized" + +# ============================================================================= +# Fund keycard holdings and AMM seed accounts from supply +# ============================================================================= +echo "" +echo "=== Fund keycard holdings and AMM seed accounts ===" + +wallet token send \ + --from "m/44'/60'/0'/0/3" \ + --to "m/44'/60'/0'/0/6" \ + --amount 20000 +echo "Transferred 20000 LEZ → keycard path 6" + +wallet token send \ + --from "m/44'/60'/0'/0/5" \ + --to "m/44'/60'/0'/0/7" \ + --amount 20000 +echo "Transferred 20000 LEE → keycard path 7" + +# Wait for path3 and path5 nonces to be confirmed before reusing them +sleep 15 + +wallet token send \ + --from "m/44'/60'/0'/0/3" \ + --to amm-lez-fund \ + --amount 10000 +echo "Transferred 10000 LEZ → amm-lez-fund" + +wallet token send \ + --from "m/44'/60'/0'/0/5" \ + --to amm-lee-fund \ + --amount 10000 +echo "Transferred 10000 LEE → amm-lee-fund" + +sleep 15 + +echo "Keycard path 6 (LEZ holding) state (balance should be 20000):" +wallet account get --account-id "m/44'/60'/0'/0/6" +echo "Keycard path 7 (LEE holding) state (balance should be 20000):" +wallet account get --account-id "m/44'/60'/0'/0/7" +echo "amm-lez-fund state (balance should be 10000):" +wallet account get --account-id amm-lez-fund +echo "amm-lee-fund state (balance should be 10000):" +wallet account get --account-id amm-lee-fund + +# ============================================================================= +# (3) Token transfer: keycard path 6 (LEZ) → public account +# ============================================================================= +echo "" +echo "=== (3) Token transfer: keycard path 6 → pub-receiver (public) ===" +wallet token send \ + --from "m/44'/60'/0'/0/6" \ + --to pub-receiver \ + --amount 1000 +echo "Transferred 1000 LEZ: keycard path 6 → pub-receiver" + +sleep 15 + +echo "Keycard path 6 (LEZ) state (balance should be 19000):" +wallet account get --account-id "m/44'/60'/0'/0/6" +echo "pub-receiver state (balance should be 1000):" +wallet account get --account-id pub-receiver + +# ============================================================================= +# (4) Token transfer: keycard path 6 (LEZ) → private account (shielded) +# ============================================================================= +echo "" +echo "=== (4) Token transfer: keycard path 6 → priv-receiver (private, shielded) ===" +PRIV_RECEIVER=$(wallet account new private | grep -o 'Private/[^[:space:]]*' | head -1) +echo "Fresh private receiver account: $PRIV_RECEIVER" + +wallet token send \ + --from "m/44'/60'/0'/0/6" \ + --to "$PRIV_RECEIVER" \ + --amount 500 +echo "Shielded transfer of 500 LEZ: keycard path 6 → $PRIV_RECEIVER" + +wallet account sync-private + +sleep 15 + +echo "Keycard path 6 (LEZ) state (balance should be 18500):" +wallet account get --account-id "m/44'/60'/0'/0/6" +echo "priv-receiver state (balance should be 500):" +wallet account get --account-id "$PRIV_RECEIVER" + +# ============================================================================= +# (5) Token transfer: private account → keycard path 6 (deshielded) +# Uses priv-receiver from test (4) which holds 500 LEZ. +# The private sender is handled by the ZK circuit; the keycard recipient +# does not sign — resolve() derives its account ID from the card only. +# ============================================================================= +echo "" +echo "=== (5) Token transfer: priv-receiver (private) → keycard path 6 (deshielded) ===" + +wallet token send \ + --from "$PRIV_RECEIVER" \ + --to "m/44'/60'/0'/0/6" \ + --amount 300 +echo "Deshielded transfer of 300 LEZ: $PRIV_RECEIVER → keycard path 6" + +wallet account sync-private + +sleep 15 + +echo "priv-receiver state (balance should be 200):" +wallet account get --account-id "$PRIV_RECEIVER" +echo "Keycard path 6 (LEZ) state (balance should be 18800):" +wallet account get --account-id "m/44'/60'/0'/0/6" + +# ============================================================================= +# (6) Token mint with keycard — definition signed by keycard path 2 +# ============================================================================= +echo "" +echo "=== (6) Token mint: keycard def path 2 mints 2000 LEZ to keycard path 6 ===" +wallet token mint \ + --definition "m/44'/60'/0'/0/2" \ + --holder "m/44'/60'/0'/0/6" \ + --amount 2000 +echo "Minted 2000 LEZ to keycard path 6" + +sleep 15 + +echo "Keycard path 2 (LEZ definition) state (total supply should have increased):" +wallet account get --account-id "m/44'/60'/0'/0/2" +echo "Keycard path 6 (LEZ holding) state (balance should be 20800):" +wallet account get --account-id "m/44'/60'/0'/0/6" + +# ============================================================================= +# (7) Token burn with keycard — holder is keycard path 6 +# ============================================================================= +echo "" +echo "=== (7) Token burn: keycard path 6 burns 500 LEZ ===" +wallet token burn \ + --definition "Public/$LEZ_DEF_ID" \ + --holder "m/44'/60'/0'/0/6" \ + --amount 500 +echo "Burned 500 LEZ from keycard path 6" + +sleep 15 + +echo "Keycard path 2 (LEZ definition) state (total supply should reflect burn):" +wallet account get --account-id "m/44'/60'/0'/0/2" +echo "Keycard path 6 (LEZ holding) state (balance should be 20300):" +wallet account get --account-id "m/44'/60'/0'/0/6" + +# ============================================================================= +# (8) Create AMM pool for LEZ/LEE — without keycard +# ============================================================================= +echo "" +echo "=== (8) Create AMM pool for LEZ/LEE (without keycard) ===" + +wallet amm new \ + --user-holding-a amm-lez-fund \ + --user-holding-b amm-lee-fund \ + --user-holding-lp amm-lp-fund \ + --balance-a 10000 \ + --balance-b 10000 +echo "AMM pool created for LEZ/LEE" + +sleep 15 + +echo "amm-lez-fund state (balance should be 0 — contributed to pool):" +wallet account get --account-id amm-lez-fund +echo "amm-lee-fund state (balance should be 0 — contributed to pool):" +wallet account get --account-id amm-lee-fund +echo "Initial LP holding state (should hold initial LP tokens):" +wallet account get --account-id amm-lp-fund +LP_DEF_ID=$(wallet account get --account-id amm-lp-fund | grep -o '"definition_id":"[^"]*"' | awk -F'"' '{print $4}') +echo "LP token definition ID: $LP_DEF_ID" + +# ============================================================================= +# (9) Swap tokens owned by keycard accounts +# keycard path 7 (LEE) sells 500 LEE; keycard path 6 (LEZ) receives LEZ +# ============================================================================= +echo "" +echo "=== (9) Swap: keycard path 7 sells 500 LEE, keycard path 6 receives LEZ ===" +wallet amm swap-exact-input \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --amount-in 500 \ + --min-amount-out 1 \ + --token-definition "$LEE_DEF_ID" +echo "Swap LEE → LEZ complete via keycard" + +sleep 15 + +echo "Keycard path 6 (LEZ holding) state (balance should have increased):" +wallet account get --account-id "m/44'/60'/0'/0/6" +echo "Keycard path 7 (LEE holding) state (balance should have decreased by 500):" +wallet account get --account-id "m/44'/60'/0'/0/7" + +# ============================================================================= +# (10) Add liquidity — keycard accounts for holding A (path 6), B (path 7), LP (path 8) +# ============================================================================= +echo "" +echo "=== (10) Add liquidity (keycard path 6=LEZ, path 7=LEE, path 8=LP) ===" +wallet amm add-liquidity \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --user-holding-lp "m/44'/60'/0'/0/8" \ + --max-amount-a 1000 \ + --max-amount-b 1000 \ + --min-amount-lp 1 +echo "Add liquidity complete via keycard" + +sleep 15 + +echo "Keycard path 6 (LEZ holding) state (balance should have decreased):" +wallet account get --account-id "m/44'/60'/0'/0/6" +echo "Keycard path 7 (LEE holding) state (balance should have decreased):" +wallet account get --account-id "m/44'/60'/0'/0/7" +echo "Keycard path 8 (LP holding) state (should have received LP tokens):" +wallet account get --account-id "m/44'/60'/0'/0/8" + +# ============================================================================= +# (11) Remove liquidity — keycard accounts for holding A (path 6), B (path 7), LP (path 8) +# ============================================================================= +echo "" +echo "=== (11) Remove liquidity (keycard path 6=LEZ, path 7=LEE, path 8=LP) ===" +wallet amm remove-liquidity \ + --user-holding-a "m/44'/60'/0'/0/6" \ + --user-holding-b "m/44'/60'/0'/0/7" \ + --user-holding-lp "m/44'/60'/0'/0/8" \ + --balance-lp 500 \ + --min-amount-a 1 \ + --min-amount-b 1 +echo "Remove liquidity complete via keycard" + +sleep 15 + +echo "Keycard path 6 (LEZ holding) state (balance should have increased):" +wallet account get --account-id "m/44'/60'/0'/0/6" +echo "Keycard path 7 (LEE holding) state (balance should have increased):" +wallet account get --account-id "m/44'/60'/0'/0/7" +echo "Keycard path 8 (LP holding) state (balance should have decreased):" +wallet account get --account-id "m/44'/60'/0'/0/8" + +# ============================================================================= +# (12) ATA create — keycard path 9 as owner for LEZ +# ============================================================================= +echo "" +echo "=== (12) ATA create: keycard path 9 as owner, LEZ token ===" +ATA_OWNER_ID=$(wallet account id --account-id "m/44'/60'/0'/0/9") +echo "ATA owner (keycard path 9): $ATA_OWNER_ID" + +wallet ata create \ + --owner "m/44'/60'/0'/0/9" \ + --token-definition "$LEZ_DEF_ID" +echo "ATA created for keycard path 9 / LEZ" + +sleep 15 + +LEZ_ATA_ID=$(wallet ata address --owner "$ATA_OWNER_ID" --token-definition "$LEZ_DEF_ID") +echo "Keycard path 9 LEZ ATA ID: $LEZ_ATA_ID" +echo "ATA state (should be initialized with zero balance):" +wallet account get --account-id "Public/$LEZ_ATA_ID" + +# Fund the ATA from LEZ supply (path 3) — setup for tests 12 and 13 +wallet token send \ + --from "m/44'/60'/0'/0/3" \ + --to "Public/$LEZ_ATA_ID" \ + --amount 3000 +echo "Funded keycard path 9 ATA with 3000 LEZ" + +sleep 15 + +echo "ATA state after funding (balance should be 3000):" +wallet account get --account-id "Public/$LEZ_ATA_ID" + +# ============================================================================= +# (13) ATA send — keycard path 9's ATA → pub-receiver's ATA +# ============================================================================= +echo "" +echo "=== (13) ATA send: keycard path 9's ATA → pub-receiver's ATA ===" +PUB_RECEIVER_ID=$(wallet account id --account-id pub-receiver) +wallet ata create \ + --owner "Public/$PUB_RECEIVER_ID" \ + --token-definition "$LEZ_DEF_ID" +echo "ATA created for pub-receiver / LEZ" + +sleep 15 + +PUB_RECEIVER_ATA_ID=$(wallet ata address --owner "$PUB_RECEIVER_ID" --token-definition "$LEZ_DEF_ID") +echo "pub-receiver LEZ ATA ID: $PUB_RECEIVER_ATA_ID" +echo "pub-receiver ATA state (should be initialized with zero balance):" +wallet account get --account-id "Public/$PUB_RECEIVER_ATA_ID" + +wallet ata send \ + --from "m/44'/60'/0'/0/9" \ + --token-definition "$LEZ_DEF_ID" \ + --to "$PUB_RECEIVER_ATA_ID" \ + --amount 500 +echo "Sent 500 LEZ: keycard path 9 ATA → pub-receiver ATA" + +sleep 15 + +echo "Keycard path 9 ATA state (balance should be 2500):" +wallet account get --account-id "Public/$LEZ_ATA_ID" +echo "pub-receiver ATA state (balance should be 500):" +wallet account get --account-id "Public/$PUB_RECEIVER_ATA_ID" + +# ============================================================================= +# (14) ATA burn — keycard path 9's ATA burns 200 LEZ +# ============================================================================= +echo "" +echo "=== (14) ATA burn: keycard path 9's ATA burns 200 LEZ ===" +wallet ata burn \ + --holder "m/44'/60'/0'/0/9" \ + --token-definition "$LEZ_DEF_ID" \ + --amount 200 +echo "Burned 200 LEZ from keycard path 9 ATA" + +sleep 15 + +echo "Keycard path 9 ATA state (balance should be 2300):" +wallet account get --account-id "Public/$LEZ_ATA_ID" +echo "LEZ definition state (total supply should reflect burn):" +wallet account get --account-id "m/44'/60'/0'/0/2" + +echo "" +echo "=== All keycard token + AMM + ATA tests finished ===" diff --git a/lez/wallet-ffi/src/transfer.rs b/lez/wallet-ffi/src/transfer.rs index 23dd3172..1fcd3133 100644 --- a/lez/wallet-ffi/src/transfer.rs +++ b/lez/wallet-ffi/src/transfer.rs @@ -1,11 +1,14 @@ //! Token transfer functions. -use std::{ffi::CString, ptr}; +use std::{ + ffi::{c_char, CStr, CString}, + ptr, +}; use lee::AccountId; use wallet::{ account::AccountIdWithPrivacy, cli::CliAccountMention, - program_facades::native_token_transfer::NativeTokenTransfer, + program_facades::native_token_transfer::NativeTokenTransfer, AccountIdentity, }; use crate::{ @@ -17,6 +20,14 @@ use crate::{ FfiPrivateAccountKeys, }; +fn optional_c_str(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + let c_str = unsafe { CStr::from_ptr(ptr) }; + c_str.to_str().ok().map(str::to_owned) +} + /// Send a public token transfer. /// /// Transfers tokens from one public account to another on the network. @@ -75,15 +86,10 @@ pub unsafe extern "C" fn wallet_ffi_transfer_public( let transfer = NativeTokenTransfer(&wallet); - 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, + AccountIdentity::Public(from_id), + AccountIdentity::Public(to_id), amount, - &from_mention, - &to_mention, )) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) @@ -140,6 +146,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( to_keys: *const FfiPrivateAccountKeys, to_identifier: *const FfiU128, amount: *const [u8; 16], + key_path: *const c_char, out_result: *mut FfiTransferResult, ) -> WalletFfiError { let wrapper = match get_wallet(handle) { @@ -176,11 +183,15 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( }; let to_identifier = u128::from_le_bytes(unsafe { (*to_identifier).data }); let amount = u128::from_le_bytes(unsafe { *amount }); + let from_mention = optional_c_str(key_path).map_or_else( + || CliAccountMention::Id(AccountIdWithPrivacy::Public(from_id)), + CliAccountMention::KeyPath, + ); let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_shielded_transfer_to_outer_account( - from_id, + from_mention.into_public_identity(from_id), to_npk, to_vpk, to_identifier, @@ -262,7 +273,6 @@ pub unsafe extern "C" fn wallet_ffi_transfer_deshielded( let from_id = AccountId::new(unsafe { (*from).data }); let to_id = AccountId::new(unsafe { (*to).data }); let amount = u128::from_le_bytes(unsafe { *amount }); - let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_deshielded_transfer(from_id, to_id, amount)) { @@ -357,7 +367,6 @@ pub unsafe extern "C" fn wallet_ffi_transfer_private( }; let to_identifier = u128::from_le_bytes(unsafe { (*to_identifier).data }); let amount = u128::from_le_bytes(unsafe { *amount }); - let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_private_transfer_to_outer_account( @@ -423,6 +432,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded_owned( from: *const FfiBytes32, to: *const FfiBytes32, amount: *const [u8; 16], + key_path: *const c_char, out_result: *mut FfiTransferResult, ) -> WalletFfiError { let wrapper = match get_wallet(handle) { @@ -446,10 +456,18 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded_owned( let from_id = AccountId::new(unsafe { (*from).data }); let to_id = AccountId::new(unsafe { (*to).data }); let amount = u128::from_le_bytes(unsafe { *amount }); + let from_mention = optional_c_str(key_path).map_or_else( + || CliAccountMention::Id(AccountIdWithPrivacy::Public(from_id)), + CliAccountMention::KeyPath, + ); let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.send_shielded_transfer(from_id, to_id, amount)) { + match block_on(transfer.send_shielded_transfer( + from_mention.into_public_identity(from_id), + to_id, + amount, + )) { Ok((tx_hash, _shared_key)) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -529,7 +547,6 @@ pub unsafe extern "C" fn wallet_ffi_transfer_private_owned( let from_id = AccountId::new(unsafe { (*from).data }); let to_id = AccountId::new(unsafe { (*to).data }); let amount = u128::from_le_bytes(unsafe { *amount }); - let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_private_transfer_to_owned_account(from_id, to_id, amount)) { @@ -603,9 +620,7 @@ pub unsafe extern "C" fn wallet_ffi_register_public_account( let transfer = NativeTokenTransfer(&wallet); - let mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id)); - - match block_on(transfer.register_account(account_id, &mention)) { + match block_on(transfer.register_account(AccountIdentity::Public(account_id))) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -673,7 +688,6 @@ pub unsafe extern "C" fn wallet_ffi_register_private_account( }; let account_id = AccountId::new(unsafe { (*account_id).data }); - let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.register_account_private(account_id)) { diff --git a/lez/wallet-ffi/wallet_ffi.h b/lez/wallet-ffi/wallet_ffi.h index 29ca5a5e..512b3b96 100644 --- a/lez/wallet-ffi/wallet_ffi.h +++ b/lez/wallet-ffi/wallet_ffi.h @@ -787,6 +787,7 @@ enum WalletFfiError wallet_ffi_transfer_shielded(struct WalletHandle *handle, const struct FfiPrivateAccountKeys *to_keys, const struct FfiU128 *to_identifier, const uint8_t (*amount)[16], + const char *key_path, struct FfiTransferResult *out_result); /** @@ -894,6 +895,7 @@ enum WalletFfiError wallet_ffi_transfer_shielded_owned(struct WalletHandle *hand const struct FfiBytes32 *from, const struct FfiBytes32 *to, const uint8_t (*amount)[16], + const char *key_path, struct FfiTransferResult *out_result); /** diff --git a/lez/wallet/Cargo.toml b/lez/wallet/Cargo.toml index 7044f557..40222715 100644 --- a/lez/wallet/Cargo.toml +++ b/lez/wallet/Cargo.toml @@ -21,7 +21,7 @@ ata_core.workspace = true bip39.workspace = true pyo3.workspace = true rpassword = "7" -zeroize = "1" +zeroize.workspace = true keycard_wallet.workspace = true anyhow.workspace = true @@ -46,6 +46,11 @@ optfield = "0.4.0" url.workspace = true derive_more = { workspace = true, features = ["display"] } +[features] +# Enables `keycard get-private-keys` command that prints sensitive secret keys to terminal. +# Never enable in production builds. +keycard-debug = [] + [dev-dependencies] tempfile.workspace = true key_protocol = { workspace = true, features = ["test_utils"] } diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index 29cb514f..ffff57c6 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -1,6 +1,7 @@ use anyhow::Result; use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; -use lee::{AccountId, PrivateKey}; +use keycard_wallet::{KeycardWallet, python_path}; +use lee::{AccountId, PrivateKey, PublicKey, Signature}; use lee_core::{ Identifier, InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, @@ -10,11 +11,16 @@ use lee_core::{ use crate::{ExecutionFailureKind, WalletCore}; -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum AccountIdentity { Public(AccountId), /// A public account without signing. Would not try to sign, even if account is owned. PublicNoSign(AccountId), + /// A public account from keycard. Mandatory signing. + PublicKeycard { + account_id: AccountId, + key_path: String, + }, PrivateOwned(AccountId), PrivateForeign { npk: NullifierPublicKey, @@ -57,7 +63,26 @@ impl AccountIdentity { /// Note: `PublicNoSign` still counts as public, the variant just suppresses the signing-key /// lookup. pub const fn is_public(&self) -> bool { - matches!(&self, Self::Public(_) | Self::PublicNoSign(_)) + matches!( + &self, + Self::Public(_) | Self::PublicNoSign(_) | Self::PublicKeycard { .. } + ) + } + + /// Returns the `AccountId` for public variants. Used by facades that need the raw ID + /// for derived-address computation alongside the identity. + #[must_use] + pub const fn public_account_id(&self) -> Option { + match self { + Self::Public(id) | Self::PublicNoSign(id) => Some(*id), + Self::PublicKeycard { account_id, .. } => Some(*account_id), + Self::PrivateOwned(_) + | Self::PrivateForeign { .. } + | Self::PrivatePdaOwned(_) + | Self::PrivatePdaForeign { .. } + | Self::PrivateShared { .. } + | Self::PrivatePdaShared { .. } => None, + } } #[must_use] @@ -86,11 +111,16 @@ enum State { account: AccountWithMetadata, sk: Option, }, + PublicKeycard { + account: AccountWithMetadata, + key_path: String, + }, Private(AccountPreparedData), } pub struct AccountManager { states: Vec, + pin: Option, } impl AccountManager { @@ -99,6 +129,7 @@ impl AccountManager { accounts: Vec, ) -> Result { let mut states = Vec::with_capacity(accounts.len()); + let mut pin = None; for account in accounts { let state = match account { @@ -124,6 +155,35 @@ impl AccountManager { State::Public { account, sk } } + AccountIdentity::PublicKeycard { + account_id, + key_path, + } => { + let acc = wallet + .get_account_public(account_id) + .await + .map_err(ExecutionFailureKind::SequencerError)?; + + let account = AccountWithMetadata::new(acc.clone(), true, account_id); + + if pin.is_none() { + pin = Some( + crate::helperfunctions::read_pin() + .map_err(|e| { + ExecutionFailureKind::KeycardError(pyo3::PyErr::new::< + pyo3::exceptions::PyRuntimeError, + _, + >( + e.to_string() + )) + })? + .as_str() + .to_owned(), + ); + } + + State::PublicKeycard { account, key_path } + } AccountIdentity::PrivateOwned(account_id) => { let pre = private_key_tree_acc_preparation(wallet, account_id, false).await?; @@ -214,27 +274,33 @@ impl AccountManager { states.push(state); } - Ok(Self { states }) + Ok(Self { states, pin }) } pub fn pre_states(&self) -> Vec { self.states .iter() .map(|state| match state { - State::Public { account, .. } => account.clone(), + State::Public { account, .. } | State::PublicKeycard { account, .. } => { + account.clone() + } State::Private(pre) => pre.pre_state.clone(), }) .collect() } pub fn public_account_nonces(&self) -> Vec { - self.states - .iter() - .filter_map(|state| match state { - State::Public { account, sk } => sk.as_ref().map(|_| account.account.nonce), - State::Private(_) => None, - }) - .collect() + // Must match the signature order produced by sign_message(): local accounts first, + // keycard accounts second. + let local = self.states.iter().filter_map(|state| match state { + State::Public { account, sk } => sk.as_ref().map(|_| account.account.nonce), + State::PublicKeycard { .. } | State::Private(_) => None, + }); + let keycard = self.states.iter().filter_map(|state| match state { + State::PublicKeycard { account, .. } => Some(account.account.nonce), + State::Public { .. } | State::Private(_) => None, + }); + local.chain(keycard).collect() } pub fn private_account_keys(&self) -> Vec { @@ -247,7 +313,7 @@ impl AccountManager { vpk: pre.vpk.clone(), epk: pre.epk.clone(), }), - State::Public { .. } => None, + State::Public { .. } | State::PublicKeycard { .. } => None, }) .collect() } @@ -260,7 +326,7 @@ impl AccountManager { self.states .iter() .map(|state| match state { - State::Public { .. } => InputAccountIdentity::Public, + State::Public { .. } | State::PublicKeycard { .. } => InputAccountIdentity::Public, State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { ssk: pre.ssk, @@ -304,21 +370,61 @@ impl AccountManager { self.states .iter() .filter_map(|state| match state { - State::Public { account, .. } => Some(account.account_id), + State::Public { account, .. } | State::PublicKeycard { account, .. } => { + Some(account.account_id) + } State::Private(_) => None, }) .collect() } - pub fn public_account_auth(&self) -> Vec<&PrivateKey> { + pub fn public_non_keycard_account_auth(&self) -> Vec<&PrivateKey> { self.states .iter() .filter_map(|state| match state { State::Public { sk, .. } => sk.as_ref(), - State::Private(_) => None, + State::PublicKeycard { .. } | State::Private(_) => None, }) .collect() } + + pub fn sign_message(&self, message_hash: [u8; 32]) -> Result> { + let mut sigs: Vec<(Signature, PublicKey)> = self + .public_non_keycard_account_auth() + .into_iter() + .map(|key| { + ( + Signature::new(key, &message_hash), + PublicKey::new_from_private_key(key), + ) + }) + .collect(); + + let keycard_paths: Vec<&str> = self + .states + .iter() + .filter_map(|state| match state { + State::PublicKeycard { key_path, .. } => Some(key_path.as_str()), + State::Private(_) | State::Public { .. } => None, + }) + .collect(); + + if let Some(pin) = self.pin.clone() { + 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 keycard_paths { + sigs.push(wallet.sign_message_for_path(py, path, &message_hash)?); + } + let _res = wallet.close_session(py); + Ok(()) + }) + .map_err(anyhow::Error::from)?; + } + + Ok(sigs) + } } struct AccountPreparedData { diff --git a/lez/wallet/src/cli/account.rs b/lez/wallet/src/cli/account.rs index c42a2378..e0ec2d0f 100644 --- a/lez/wallet/src/cli/account.rs +++ b/lez/wallet/src/cli/account.rs @@ -15,6 +15,12 @@ use crate::{ /// Represents generic chain CLI subcommand. #[derive(Subcommand, Debug, Clone)] pub enum AccountSubcommand { + /// Resolve an account mention and print just the account ID (no privacy prefix). + Id { + /// Account id with privacy prefix, label, or BIP-32 key path. + #[arg(long)] + account_id: CliAccountMention, + }, /// Get account data. Get { /// Flag to get raw account data. @@ -275,6 +281,14 @@ impl WalletSubcommand for AccountSubcommand { wallet_core: &mut WalletCore, ) -> Result { match self { + Self::Id { account_id } => { + let resolved = account_id.resolve(wallet_core.storage())?; + let id = match resolved { + AccountIdWithPrivacy::Public(id) | AccountIdWithPrivacy::Private(id) => id, + }; + println!("{id}"); + Ok(SubcommandReturnValue::Empty) + } Self::Get { raw, keys, diff --git a/lez/wallet/src/cli/keycard.rs b/lez/wallet/src/cli/keycard.rs index ead1e84b..1dec07d9 100644 --- a/lez/wallet/src/cli/keycard.rs +++ b/lez/wallet/src/cli/keycard.rs @@ -16,6 +16,20 @@ pub enum KeycardSubcommand { Disconnect, Init, Load, + /// Retrieve the private keys (NSK, VSK) for a given BIP-32 key path. + /// + /// Prints raw key material to stdout — intended for debugging only. + /// Requires --reveal to confirm intent. + /// Only available when built with the `keycard-debug` feature. + #[cfg(feature = "keycard-debug")] + GetPrivateKeys { + /// BIP-32 derivation path, e.g. `m/44'/60'/0'/0/0`. + #[arg(long)] + key_path: String, + /// Confirm that raw NSK and VSK should be disclosed on stdout. + #[arg(long)] + reveal: bool, + }, } impl WalletSubcommand for KeycardSubcommand { @@ -26,7 +40,8 @@ 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"); @@ -47,7 +62,8 @@ 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"); @@ -66,7 +82,8 @@ 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"); @@ -89,7 +106,8 @@ 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"); @@ -111,7 +129,8 @@ 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"); @@ -131,6 +150,27 @@ impl WalletSubcommand for KeycardSubcommand { Ok(SubcommandReturnValue::Empty) } + #[cfg(feature = "keycard-debug")] + Self::GetPrivateKeys { key_path, reveal } => { + if !reveal { + eprintln!( + "WARNING: pass --reveal to print NSK and VSK. \ + Disclosing either key fully compromises the account's privacy." + ); + return Ok(SubcommandReturnValue::Empty); + } + eprintln!( + "WARNING: NSK and VSK are being printed to stdout. \ + Any terminal log, scrollback, or screen recording captures these keys." + ); + let pin = read_pin()?; + let (nsk, vsk) = + KeycardWallet::get_private_keys_for_path_with_connect(&pin, &key_path) + .map_err(anyhow::Error::from)?; + println!("NSK: {}", hex::encode(*nsk)); + println!("VSK: {}", hex::encode(*vsk)); + Ok(SubcommandReturnValue::Empty) + } } } } diff --git a/lez/wallet/src/cli/mod.rs b/lez/wallet/src/cli/mod.rs index 99b1f0b2..57c40b95 100644 --- a/lez/wallet/src/cli/mod.rs +++ b/lez/wallet/src/cli/mod.rs @@ -138,13 +138,34 @@ impl CliAccountMention { 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)?; + keycard_wallet::KeycardWallet::get_public_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}")) } } } + + #[must_use] + pub fn key_path(&self) -> Option<&str> { + match self { + Self::KeyPath(path) => Some(path), + Self::Id(_) | Self::Label(_) => None, + } + } + + #[must_use] + pub fn into_public_identity(self, account_id: lee::AccountId) -> crate::AccountIdentity { + match self { + Self::KeyPath(key_path) => crate::AccountIdentity::PublicKeycard { + account_id, + key_path, + }, + Self::Id(_) | Self::Label(_) => crate::AccountIdentity::Public(account_id), + } + } } impl FromStr for CliAccountMention { diff --git a/lez/wallet/src/cli/programs/amm.rs b/lez/wallet/src/cli/programs/amm.rs index 532458de..6cdeb07c 100644 --- a/lez/wallet/src/cli/programs/amm.rs +++ b/lez/wallet/src/cli/programs/amm.rs @@ -131,25 +131,28 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { balance_a, balance_b, } => { - let user_holding_a = user_holding_a.resolve(wallet_core.storage())?; - let user_holding_b = user_holding_b.resolve(wallet_core.storage())?; - let user_holding_lp = user_holding_lp.resolve(wallet_core.storage())?; - match (user_holding_a, user_holding_b, user_holding_lp) { + let a_id = user_holding_a.resolve(wallet_core.storage())?; + let b_id = user_holding_b.resolve(wallet_core.storage())?; + let lp_id = user_holding_lp.resolve(wallet_core.storage())?; + match (a_id, b_id, lp_id) { ( - AccountIdWithPrivacy::Public(user_holding_a), - AccountIdWithPrivacy::Public(user_holding_b), - AccountIdWithPrivacy::Public(user_holding_lp), + AccountIdWithPrivacy::Public(a), + AccountIdWithPrivacy::Public(b), + AccountIdWithPrivacy::Public(lp), ) => { - Amm(wallet_core) + let tx_hash = Amm(wallet_core) .send_new_definition( - user_holding_a, - user_holding_b, - user_holding_lp, + user_holding_a.into_public_identity(a), + user_holding_b.into_public_identity(b), + user_holding_lp.into_public_identity(lp), balance_a, balance_b, ) .await?; - + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } _ => { @@ -165,23 +168,23 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { min_amount_out, token_definition, } => { - let user_holding_a = user_holding_a.resolve(wallet_core.storage())?; - let user_holding_b = user_holding_b.resolve(wallet_core.storage())?; - match (user_holding_a, user_holding_b) { - ( - AccountIdWithPrivacy::Public(user_holding_a), - AccountIdWithPrivacy::Public(user_holding_b), - ) => { - Amm(wallet_core) + let a_id = user_holding_a.resolve(wallet_core.storage())?; + let b_id = user_holding_b.resolve(wallet_core.storage())?; + match (a_id, b_id) { + (AccountIdWithPrivacy::Public(a), AccountIdWithPrivacy::Public(b)) => { + let tx_hash = Amm(wallet_core) .send_swap_exact_input( - user_holding_a, - user_holding_b, + user_holding_a.into_public_identity(a), + user_holding_b.into_public_identity(b), amount_in, min_amount_out, token_definition, ) .await?; - + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } _ => { @@ -197,23 +200,23 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { max_amount_in, token_definition, } => { - let user_holding_a = user_holding_a.resolve(wallet_core.storage())?; - let user_holding_b = user_holding_b.resolve(wallet_core.storage())?; - match (user_holding_a, user_holding_b) { - ( - AccountIdWithPrivacy::Public(user_holding_a), - AccountIdWithPrivacy::Public(user_holding_b), - ) => { - Amm(wallet_core) + let a_id = user_holding_a.resolve(wallet_core.storage())?; + let b_id = user_holding_b.resolve(wallet_core.storage())?; + match (a_id, b_id) { + (AccountIdWithPrivacy::Public(a), AccountIdWithPrivacy::Public(b)) => { + let tx_hash = Amm(wallet_core) .send_swap_exact_output( - user_holding_a, - user_holding_b, + user_holding_a.into_public_identity(a), + user_holding_b.into_public_identity(b), exact_amount_out, max_amount_in, token_definition, ) .await?; - + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } _ => { @@ -230,26 +233,29 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { max_amount_a, max_amount_b, } => { - let user_holding_a = user_holding_a.resolve(wallet_core.storage())?; - let user_holding_b = user_holding_b.resolve(wallet_core.storage())?; - let user_holding_lp = user_holding_lp.resolve(wallet_core.storage())?; - match (user_holding_a, user_holding_b, user_holding_lp) { + let a_id = user_holding_a.resolve(wallet_core.storage())?; + let b_id = user_holding_b.resolve(wallet_core.storage())?; + let lp_id = user_holding_lp.resolve(wallet_core.storage())?; + match (a_id, b_id, lp_id) { ( - AccountIdWithPrivacy::Public(user_holding_a), - AccountIdWithPrivacy::Public(user_holding_b), - AccountIdWithPrivacy::Public(user_holding_lp), + AccountIdWithPrivacy::Public(a), + AccountIdWithPrivacy::Public(b), + AccountIdWithPrivacy::Public(lp), ) => { - Amm(wallet_core) + let tx_hash = Amm(wallet_core) .send_add_liquidity( - user_holding_a, - user_holding_b, - user_holding_lp, + user_holding_a.into_public_identity(a), + user_holding_b.into_public_identity(b), + user_holding_lp.into_public_identity(lp), min_amount_lp, max_amount_a, max_amount_b, ) .await?; - + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } _ => { @@ -266,26 +272,29 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { min_amount_a, min_amount_b, } => { - let user_holding_a = user_holding_a.resolve(wallet_core.storage())?; - let user_holding_b = user_holding_b.resolve(wallet_core.storage())?; - let user_holding_lp = user_holding_lp.resolve(wallet_core.storage())?; - match (user_holding_a, user_holding_b, user_holding_lp) { + let a_id = user_holding_a.resolve(wallet_core.storage())?; + let b_id = user_holding_b.resolve(wallet_core.storage())?; + let lp_id = user_holding_lp.resolve(wallet_core.storage())?; + match (a_id, b_id, lp_id) { ( - AccountIdWithPrivacy::Public(user_holding_a), - AccountIdWithPrivacy::Public(user_holding_b), - AccountIdWithPrivacy::Public(user_holding_lp), + AccountIdWithPrivacy::Public(a), + AccountIdWithPrivacy::Public(b), + AccountIdWithPrivacy::Public(lp), ) => { - Amm(wallet_core) + let tx_hash = Amm(wallet_core) .send_remove_liquidity( - user_holding_a, - user_holding_b, - user_holding_lp, + a, + b, + user_holding_lp.into_public_identity(lp), balance_lp, min_amount_a, min_amount_b, ) .await?; - + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } _ => { diff --git a/lez/wallet/src/cli/programs/ata.rs b/lez/wallet/src/cli/programs/ata.rs index dab67cc6..b77ff61f 100644 --- a/lez/wallet/src/cli/programs/ata.rs +++ b/lez/wallet/src/cli/programs/ata.rs @@ -91,14 +91,18 @@ impl WalletSubcommand for AtaSubcommand { owner, token_definition, } => { - let owner = owner.resolve(wallet_core.storage())?; + let owner_resolved = owner.resolve(wallet_core.storage())?; let definition_id = token_definition; - match owner { + match owner_resolved { AccountIdWithPrivacy::Public(owner_id) => { - Ata(wallet_core) - .send_create(owner_id, definition_id) + let tx_hash = Ata(wallet_core) + .send_create(owner.into_public_identity(owner_id), definition_id) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } AccountIdWithPrivacy::Private(owner_id) => { @@ -127,15 +131,24 @@ impl WalletSubcommand for AtaSubcommand { to, amount, } => { - let from = from.resolve(wallet_core.storage())?; + let from_resolved = from.resolve(wallet_core.storage())?; let definition_id = token_definition; let to_id = to; - match from { + match from_resolved { AccountIdWithPrivacy::Public(from_id) => { - Ata(wallet_core) - .send_transfer(from_id, definition_id, to_id, amount) + let tx_hash = Ata(wallet_core) + .send_transfer( + from.into_public_identity(from_id), + definition_id, + to_id, + amount, + ) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } AccountIdWithPrivacy::Private(from_id) => { @@ -163,14 +176,22 @@ impl WalletSubcommand for AtaSubcommand { token_definition, amount, } => { - let holder = holder.resolve(wallet_core.storage())?; + let holder_resolved = holder.resolve(wallet_core.storage())?; let definition_id = token_definition; - match holder { + match holder_resolved { AccountIdWithPrivacy::Public(holder_id) => { - Ata(wallet_core) - .send_burn(holder_id, definition_id, amount) + let tx_hash = Ata(wallet_core) + .send_burn( + holder.into_public_identity(holder_id), + definition_id, + amount, + ) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } AccountIdWithPrivacy::Private(holder_id) => { diff --git a/lez/wallet/src/cli/programs/native_token_transfer.rs b/lez/wallet/src/cli/programs/native_token_transfer.rs index 695dae5f..eba1da0c 100644 --- a/lez/wallet/src/cli/programs/native_token_transfer.rs +++ b/lez/wallet/src/cli/programs/native_token_transfer.rs @@ -5,7 +5,7 @@ use lee::AccountId; use crate::{ AccDecodeData::Decode, - WalletCore, + AccountIdentity, WalletCore, account::AccountIdWithPrivacy, cli::{CliAccountMention, SubcommandReturnValue, WalletSubcommand}, program_facades::native_token_transfer::NativeTokenTransfer, @@ -64,7 +64,7 @@ impl WalletSubcommand for AuthTransferSubcommand { match resolved { AccountIdWithPrivacy::Public(pub_account_id) => { let tx_hash = NativeTokenTransfer(wallet_core) - .register_account(pub_account_id, &account_id) + .register_account(account_id.into_public_identity(pub_account_id)) .await?; println!("Transaction hash is {tx_hash}"); @@ -137,12 +137,11 @@ impl WalletSubcommand for AuthTransferSubcommand { } (Some(to), None, None) => match (from, to) { (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Public(to)) => { + let to_mention = to_account.expect("matched Some branch"); NativeTokenTransferProgramSubcommand::Public { - from, - to, + from: Some(from_account.into_public_identity(from)), + to: Some(to_mention.into_public_identity(to)), amount, - from_mention: from_account, - to_mention: to_account.expect("matched Some branch"), } } ( @@ -161,7 +160,7 @@ impl WalletSubcommand for AuthTransferSubcommand { (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Private(to)) => { NativeTokenTransferProgramSubcommand::Shielded( NativeTokenTransferProgramSubcommandShielded::ShieldedOwned { - from, + from: Some(from_account.into_public_identity(from)), to, amount, }, @@ -183,7 +182,7 @@ impl WalletSubcommand for AuthTransferSubcommand { AccountIdWithPrivacy::Public(from) => { NativeTokenTransferProgramSubcommand::Shielded( NativeTokenTransferProgramSubcommandShielded::ShieldedForeign { - from, + from: Some(from_account.into_public_identity(from)), to_npk, to_vpk, to_identifier, @@ -207,19 +206,13 @@ pub enum NativeTokenTransferProgramSubcommand { /// /// Public operation. Public { - /// from - valid 32 byte hex string. - #[arg(long)] - from: AccountId, - /// to - valid 32 byte hex string. - #[arg(long)] - to: AccountId, + #[arg(skip)] + from: Option, + #[arg(skip)] + to: Option, /// amount - amount of balance to move. #[arg(long)] amount: u128, - #[arg(skip)] - from_mention: CliAccountMention, - #[arg(skip)] - to_mention: CliAccountMention, }, /// Private execution. #[command(subcommand)] @@ -252,8 +245,8 @@ pub enum NativeTokenTransferProgramSubcommandShielded { /// Shielded operation. ShieldedOwned { /// from - valid 32 byte hex string. - #[arg(long)] - from: AccountId, + #[arg(skip)] + from: Option, /// to - valid 32 byte hex string. #[arg(long)] to: AccountId, @@ -265,9 +258,8 @@ pub enum NativeTokenTransferProgramSubcommandShielded { /// /// Shielded operation. ShieldedForeign { - /// from - valid 32 byte hex string. - #[arg(long)] - from: AccountId, + #[arg(skip)] + from: Option, /// `to_npk` - valid 32 byte hex string. #[arg(long)] to_npk: String, @@ -407,7 +399,11 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded { match self { Self::ShieldedOwned { from, to, amount } => { let (tx_hash, secret) = NativeTokenTransfer(wallet_core) - .send_shielded_transfer(from, to, amount) + .send_shielded_transfer( + from.expect("from set during Send dispatch"), + to, + amount, + ) .await?; println!("Transaction hash is {tx_hash}"); @@ -446,7 +442,7 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded { let (tx_hash, _) = NativeTokenTransfer(wallet_core) .send_shielded_transfer_to_outer_account( - from, + from.expect("from set during Send dispatch"), to_npk, to_vpk, to_identifier.unwrap_or_else(rand::random), @@ -498,15 +494,13 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommand { Ok(SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash }) } - Self::Public { - from, - to, - amount, - from_mention, - to_mention, - } => { + Self::Public { from, to, amount } => { let tx_hash = NativeTokenTransfer(wallet_core) - .send_public_transfer(from, to, amount, &from_mention, &to_mention) + .send_public_transfer( + from.expect("from is set during Send dispatch"), + to.expect("to is set during Send dispatch"), + amount, + ) .await?; println!("Transaction hash is {tx_hash}"); diff --git a/lez/wallet/src/cli/programs/token.rs b/lez/wallet/src/cli/programs/token.rs index 5fb8a351..4a64f902 100644 --- a/lez/wallet/src/cli/programs/token.rs +++ b/lez/wallet/src/cli/programs/token.rs @@ -5,7 +5,7 @@ use lee::AccountId; use crate::{ AccDecodeData::Decode, - WalletCore, + AccountIdentity, WalletCore, account::AccountIdWithPrivacy, cli::{CliAccountMention, SubcommandReturnValue, WalletSubcommand}, program_facades::token::Token, @@ -122,20 +122,21 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { name, total_supply, } => { + let def_mention = definition_account_id.clone(); + let sup_mention = supply_account_id.clone(); let definition_account_id = definition_account_id.resolve(wallet_core.storage())?; let supply_account_id = supply_account_id.resolve(wallet_core.storage())?; let underlying_subcommand = match (definition_account_id, supply_account_id) { - ( - AccountIdWithPrivacy::Public(definition_account_id), - AccountIdWithPrivacy::Public(supply_account_id), - ) => TokenProgramSubcommand::Create( - CreateNewTokenProgramSubcommand::NewPublicDefPublicSupp { - definition_account_id, - supply_account_id, - name, - total_supply, - }, - ), + (AccountIdWithPrivacy::Public(_), AccountIdWithPrivacy::Public(_)) => { + TokenProgramSubcommand::Create( + CreateNewTokenProgramSubcommand::NewPublicDefPublicSupp { + definition_account_id: def_mention, + supply_account_id: sup_mention, + name, + total_supply, + }, + ) + } ( AccountIdWithPrivacy::Public(definition_account_id), AccountIdWithPrivacy::Private(supply_account_id), @@ -182,6 +183,8 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { to_identifier, amount, } => { + let from_mention = from.clone(); + let to_mention = to.clone(); let (to_npk, to_vpk) = if let Some(path) = to_keys { let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?; (Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes))) @@ -208,11 +211,11 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { anyhow::bail!("List of public keys is uncomplete"); } (Some(to), None, None) => match (from, to) { - (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Public(to)) => { + (AccountIdWithPrivacy::Public(_), AccountIdWithPrivacy::Public(_)) => { TokenProgramSubcommand::Public( TokenProgramSubcommandPublic::TransferToken { - sender_account_id: from, - recipient_account_id: to, + sender_account_id: from_mention, + recipient_account_id: to_mention.expect("`wallet::cli::programs::token::Send`: Invalid to_mention account provided"), balance_to_move: amount, }, ) @@ -239,7 +242,7 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { (AccountIdWithPrivacy::Public(from), AccountIdWithPrivacy::Private(to)) => { TokenProgramSubcommand::Shielded( TokenProgramSubcommandShielded::TransferTokenShieldedOwned { - sender_account_id: from, + sender: Some(from_mention.into_public_identity(from)), recipient_account_id: to, balance_to_move: amount, }, @@ -258,7 +261,7 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { ), AccountIdWithPrivacy::Public(from) => TokenProgramSubcommand::Shielded( TokenProgramSubcommandShielded::TransferTokenShieldedForeign { - sender_account_id: from, + sender: Some(from_mention.into_public_identity(from)), recipient_npk: to_npk, recipient_vpk: to_vpk, recipient_identifier: to_identifier, @@ -275,17 +278,17 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { holder, amount, } => { + let holder_mention = holder.clone(); let definition = definition.resolve(wallet_core.storage())?; let holder = holder.resolve(wallet_core.storage())?; let underlying_subcommand = match (definition, holder) { - ( - AccountIdWithPrivacy::Public(definition), - AccountIdWithPrivacy::Public(holder), - ) => TokenProgramSubcommand::Public(TokenProgramSubcommandPublic::BurnToken { - definition_account_id: definition, - holder_account_id: holder, - amount, - }), + (AccountIdWithPrivacy::Public(definition), AccountIdWithPrivacy::Public(_)) => { + TokenProgramSubcommand::Public(TokenProgramSubcommandPublic::BurnToken { + definition_account_id: definition, + holder_account_id: holder_mention, + amount, + }) + } ( AccountIdWithPrivacy::Private(definition), AccountIdWithPrivacy::Private(holder), @@ -329,6 +332,8 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { holder_identifier, amount, } => { + let def_mention = definition.clone(); + let holder_mention = holder.clone(); let (holder_npk, holder_vpk) = if let Some(path) = holder_keys { let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?; (Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes))) @@ -355,16 +360,15 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { anyhow::bail!("List of public keys is uncomplete"); } (Some(holder), None, None) => match (definition, holder) { - ( - AccountIdWithPrivacy::Public(definition), - AccountIdWithPrivacy::Public(holder), - ) => TokenProgramSubcommand::Public( - TokenProgramSubcommandPublic::MintToken { - definition_account_id: definition, - holder_account_id: holder, - amount, - }, - ), + (AccountIdWithPrivacy::Public(_), AccountIdWithPrivacy::Public(_)) => { + TokenProgramSubcommand::Public( + TokenProgramSubcommandPublic::MintToken { + definition_account_id: def_mention, + holder_account_id: holder_mention.expect("`wallet::cli::programs::token::Mint`: Invalid holder_mention account provided"), + amount, + }, + ) + } ( AccountIdWithPrivacy::Private(definition), AccountIdWithPrivacy::Private(holder), @@ -454,9 +458,9 @@ pub enum TokenProgramSubcommandPublic { // Transfer tokens using the token program TransferToken { #[arg(short, long)] - sender_account_id: AccountId, + sender_account_id: CliAccountMention, #[arg(short, long)] - recipient_account_id: AccountId, + recipient_account_id: CliAccountMention, #[arg(short, long)] balance_to_move: u128, }, @@ -465,16 +469,16 @@ pub enum TokenProgramSubcommandPublic { #[arg(short, long)] definition_account_id: AccountId, #[arg(short, long)] - holder_account_id: AccountId, + holder_account_id: CliAccountMention, #[arg(short, long)] amount: u128, }, // Transfer tokens using the token program MintToken { #[arg(short, long)] - definition_account_id: AccountId, + definition_account_id: CliAccountMention, #[arg(short, long)] - holder_account_id: AccountId, + holder_account_id: CliAccountMention, #[arg(short, long)] amount: u128, }, @@ -579,8 +583,8 @@ pub enum TokenProgramSubcommandDeshielded { pub enum TokenProgramSubcommandShielded { // Transfer tokens using the token program TransferTokenShieldedOwned { - #[arg(short, long)] - sender_account_id: AccountId, + #[arg(skip)] + sender: Option, #[arg(short, long)] recipient_account_id: AccountId, #[arg(short, long)] @@ -588,8 +592,8 @@ pub enum TokenProgramSubcommandShielded { }, // Transfer tokens using the token program TransferTokenShieldedForeign { - #[arg(short, long)] - sender_account_id: AccountId, + #[arg(skip)] + sender: Option, /// `recipient_npk` - valid 32 byte hex string. #[arg(long)] recipient_npk: String, @@ -644,9 +648,9 @@ pub enum CreateNewTokenProgramSubcommand { /// Definition - public, supply - public. NewPublicDefPublicSupp { #[arg(short, long)] - definition_account_id: AccountId, + definition_account_id: CliAccountMention, #[arg(short, long)] - supply_account_id: AccountId, + supply_account_id: CliAccountMention, #[arg(short, long)] name: String, #[arg(short, long)] @@ -704,13 +708,28 @@ impl WalletSubcommand for TokenProgramSubcommandPublic { recipient_account_id, balance_to_move, } => { - Token(wallet_core) + let sender = sender_account_id.resolve(wallet_core.storage())?; + let recipient = recipient_account_id.resolve(wallet_core.storage())?; + let ( + AccountIdWithPrivacy::Public(sender_id), + AccountIdWithPrivacy::Public(recipient_id), + ) = (sender, recipient) + else { + anyhow::bail!( + "`TokenProgramSubcommandPublic::TransferToken`: Unexpected private account received." + ); + }; + let tx_hash = Token(wallet_core) .send_transfer_transaction( - sender_account_id, - recipient_account_id, + sender_account_id.into_public_identity(sender_id), + recipient_account_id.into_public_identity(recipient_id), balance_to_move, ) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } Self::BurnToken { @@ -718,9 +737,23 @@ impl WalletSubcommand for TokenProgramSubcommandPublic { holder_account_id, amount, } => { - Token(wallet_core) - .send_burn_transaction(definition_account_id, holder_account_id, amount) + let holder = holder_account_id.resolve(wallet_core.storage())?; + let AccountIdWithPrivacy::Public(holder_id) = holder else { + anyhow::bail!( + "`TokenProgramSubcommandPublic::BurnToken`: holder account must be public." + ); + }; + let tx_hash = Token(wallet_core) + .send_burn_transaction( + definition_account_id, + holder_account_id.into_public_identity(holder_id), + amount, + ) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } Self::MintToken { @@ -728,9 +761,26 @@ impl WalletSubcommand for TokenProgramSubcommandPublic { holder_account_id, amount, } => { - Token(wallet_core) - .send_mint_transaction(definition_account_id, holder_account_id, amount) + let definition = definition_account_id.resolve(wallet_core.storage())?; + let holder = holder_account_id.resolve(wallet_core.storage())?; + let (AccountIdWithPrivacy::Public(def_id), AccountIdWithPrivacy::Public(holder_id)) = + (definition, holder) + else { + anyhow::bail!( + "`TokenProgramSubcommandPublic::MintToken`: holder account must be public." + ); + }; + let tx_hash = Token(wallet_core) + .send_mint_transaction( + definition_account_id.into_public_identity(def_id), + holder_account_id.into_public_identity(holder_id), + amount, + ) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } } @@ -1044,7 +1094,7 @@ impl WalletSubcommand for TokenProgramSubcommandShielded { ) -> Result { match self { Self::TransferTokenShieldedForeign { - sender_account_id, + sender, recipient_npk, recipient_vpk, recipient_identifier, @@ -1064,7 +1114,7 @@ impl WalletSubcommand for TokenProgramSubcommandShielded { let (tx_hash, _) = Token(wallet_core) .send_transfer_transaction_shielded_foreign_account( - sender_account_id, + sender.expect("sender set during Send dispatch"), recipient_npk, recipient_vpk, recipient_identifier.unwrap_or_else(rand::random), @@ -1085,13 +1135,13 @@ impl WalletSubcommand for TokenProgramSubcommandShielded { Ok(SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash }) } Self::TransferTokenShieldedOwned { - sender_account_id, + sender, recipient_account_id, balance_to_move, } => { let (tx_hash, secret_recipient) = Token(wallet_core) .send_transfer_transaction_shielded_owned_account( - sender_account_id, + sender.expect("sender set during Send dispatch"), recipient_account_id, balance_to_move, ) @@ -1329,14 +1379,25 @@ impl WalletSubcommand for CreateNewTokenProgramSubcommand { name, total_supply, } => { - Token(wallet_core) + let definition = definition_account_id.resolve(wallet_core.storage())?; + let supply = supply_account_id.resolve(wallet_core.storage())?; + let (AccountIdWithPrivacy::Public(def_id), AccountIdWithPrivacy::Public(sup_id)) = + (definition, supply) + else { + anyhow::bail!("`NewPublicDefPublicSupp`: unexpected private account received."); + }; + let tx_hash = Token(wallet_core) .send_new_definition( - definition_account_id, - supply_account_id, + definition_account_id.into_public_identity(def_id), + supply_account_id.into_public_identity(sup_id), name, total_supply, ) .await?; + println!("Transaction hash is {tx_hash}"); + let transfer_tx = wallet_core.poll_native_token_transfer(tx_hash).await?; + println!("Transaction data is {transfer_tx:?}"); + wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::Empty) } } diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index 79a223fe..0afcda6d 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -1,5 +1,6 @@ #![expect( clippy::print_stdout, + clippy::print_stderr, reason = "This is a CLI application, printing to stdout and stderr is expected and convenient" )] #![expect( @@ -76,6 +77,8 @@ pub enum ExecutionFailureKind { AccountDataError(AccountId), #[error("Failed to build transaction: {0}")] TransactionBuildError(#[from] lee::error::LeeError), + #[error("Failed to sign transaction: {0}")] + SignError(anyhow::Error), #[error(transparent)] KeycardError(#[from] pyo3::PyErr), } @@ -564,6 +567,7 @@ impl WalletCore { let acc_manager = account_manager::AccountManager::new(self, accounts).await?; let pre_states = acc_manager.pre_states(); + tx_pre_check( &pre_states .iter() @@ -577,26 +581,30 @@ impl WalletCore { instruction_data, acc_manager.account_identities(), &program.to_owned(), - ) - .unwrap(); + )?; let message = lee::privacy_preserving_transaction::message::Message::try_from_circuit_output( acc_manager.public_account_ids(), - Vec::from_iter(acc_manager.public_account_nonces()), + acc_manager.public_account_nonces(), private_account_keys .iter() .map(|keys| (keys.npk, keys.vpk.clone(), keys.epk.clone())) .collect(), output, - ) - .unwrap(); + )?; + + let message_hash = message.hash(); + let signatures_public_keys = acc_manager + .sign_message(message_hash) + .map_err(ExecutionFailureKind::SignError)?; + + let witness_set = + lee::privacy_preserving_transaction::witness_set::WitnessSet::from_raw_parts( + signatures_public_keys, + proof, + ); - let witness_set = lee::privacy_preserving_transaction::witness_set::WitnessSet::for_message( - &message, - proof, - &acc_manager.public_account_auth(), - ); let tx = PrivacyPreservingTransaction::new(message, witness_set); let shared_secrets: Vec<_> = private_account_keys @@ -651,7 +659,6 @@ impl WalletCore { let account_ids = acc_manager.public_account_ids(); let program_id = program.program.id(); let nonces = acc_manager.public_account_nonces(); - let private_keys = acc_manager.public_account_auth(); let message = lee::public_transaction::Message::new_preserialized( program_id, @@ -660,7 +667,13 @@ impl WalletCore { instruction_data, ); - let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &private_keys); + let message_hash = message.hash(); + let signatures_public_keys = acc_manager + .sign_message(message_hash) + .map_err(ExecutionFailureKind::SignError)?; + + let witness_set = + lee::public_transaction::WitnessSet::from_raw_parts(signatures_public_keys); let tx = lee::public_transaction::PublicTransaction::new(message, witness_set); diff --git a/lez/wallet/src/program_facades/amm.rs b/lez/wallet/src/program_facades/amm.rs index 5c5f69b7..e1261b26 100644 --- a/lez/wallet/src/program_facades/amm.rs +++ b/lez/wallet/src/program_facades/amm.rs @@ -9,30 +9,37 @@ pub struct Amm<'wallet>(pub &'wallet WalletCore); impl Amm<'_> { pub async fn send_new_definition( &self, - user_holding_a: AccountId, - user_holding_b: AccountId, - user_holding_lp: AccountId, + user_holding_a: AccountIdentity, + user_holding_b: AccountIdentity, + user_holding_lp: AccountIdentity, balance_a: u128, balance_b: u128, ) -> Result { + let a_id = user_holding_a + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let b_id = user_holding_b + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::amm(); let amm_program_id = Program::amm().id(); let user_a_acc = self .0 - .get_account_public(user_holding_a) + .get_account_public(a_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let user_b_acc = self .0 - .get_account_public(user_holding_b) + .get_account_public(b_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let definition_token_a_id = TokenHolding::try_from(&user_a_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_a))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(a_id))? .definition_id(); let definition_token_b_id = TokenHolding::try_from(&user_b_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_b))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(b_id))? .definition_id(); let amm_pool = @@ -55,9 +62,9 @@ impl Amm<'_> { AccountIdentity::PublicNoSign(vault_holding_a), AccountIdentity::PublicNoSign(vault_holding_b), AccountIdentity::PublicNoSign(pool_lp), - AccountIdentity::Public(user_holding_a), - AccountIdentity::Public(user_holding_b), - AccountIdentity::Public(user_holding_lp), + user_holding_a, + user_holding_b, + user_holding_lp, ], instruction_data, &program.into(), @@ -67,30 +74,37 @@ impl Amm<'_> { pub async fn send_swap_exact_input( &self, - user_holding_a: AccountId, - user_holding_b: AccountId, + user_holding_a: AccountIdentity, + user_holding_b: AccountIdentity, swap_amount_in: u128, min_amount_out: u128, token_definition_id_in: AccountId, ) -> Result { + let a_id = user_holding_a + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let b_id = user_holding_b + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::amm(); let amm_program_id = Program::amm().id(); let user_a_acc = self .0 - .get_account_public(user_holding_a) + .get_account_public(a_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let user_b_acc = self .0 - .get_account_public(user_holding_b) + .get_account_public(b_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let definition_token_a_id = TokenHolding::try_from(&user_a_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_a))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(a_id))? .definition_id(); let definition_token_b_id = TokenHolding::try_from(&user_b_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_b))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(b_id))? .definition_id(); let amm_pool = @@ -114,15 +128,15 @@ impl Amm<'_> { } let user_a_signing_identity = if token_definition_id_in == definition_token_a_id { - AccountIdentity::Public(user_holding_a) + user_holding_a } else { - AccountIdentity::PublicNoSign(user_holding_a) + AccountIdentity::PublicNoSign(a_id) }; let user_b_signing_identity = if token_definition_id_in == definition_token_b_id { - AccountIdentity::Public(user_holding_b) + user_holding_b } else { - AccountIdentity::PublicNoSign(user_holding_b) + AccountIdentity::PublicNoSign(b_id) }; self.0 @@ -142,30 +156,37 @@ impl Amm<'_> { pub async fn send_swap_exact_output( &self, - user_holding_a: AccountId, - user_holding_b: AccountId, + user_holding_a: AccountIdentity, + user_holding_b: AccountIdentity, exact_amount_out: u128, max_amount_in: u128, token_definition_id_in: AccountId, ) -> Result { + let a_id = user_holding_a + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let b_id = user_holding_b + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::amm(); let amm_program_id = Program::amm().id(); let user_a_acc = self .0 - .get_account_public(user_holding_a) + .get_account_public(a_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let user_b_acc = self .0 - .get_account_public(user_holding_b) + .get_account_public(b_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let definition_token_a_id = TokenHolding::try_from(&user_a_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_a))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(a_id))? .definition_id(); let definition_token_b_id = TokenHolding::try_from(&user_b_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_b))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(b_id))? .definition_id(); let amm_pool = @@ -189,15 +210,15 @@ impl Amm<'_> { } let user_a_signing_identity = if token_definition_id_in == definition_token_a_id { - AccountIdentity::Public(user_holding_a) + user_holding_a } else { - AccountIdentity::PublicNoSign(user_holding_a) + AccountIdentity::PublicNoSign(a_id) }; let user_b_signing_identity = if token_definition_id_in == definition_token_b_id { - AccountIdentity::Public(user_holding_b) + user_holding_b } else { - AccountIdentity::PublicNoSign(user_holding_b) + AccountIdentity::PublicNoSign(b_id) }; self.0 @@ -217,31 +238,38 @@ impl Amm<'_> { pub async fn send_add_liquidity( &self, - user_holding_a: AccountId, - user_holding_b: AccountId, - user_holding_lp: AccountId, + user_holding_a: AccountIdentity, + user_holding_b: AccountIdentity, + user_holding_lp: AccountIdentity, min_amount_liquidity: u128, max_amount_to_add_token_a: u128, max_amount_to_add_token_b: u128, ) -> Result { + let a_id = user_holding_a + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let b_id = user_holding_b + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::amm(); let amm_program_id = Program::amm().id(); let user_a_acc = self .0 - .get_account_public(user_holding_a) + .get_account_public(a_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let user_b_acc = self .0 - .get_account_public(user_holding_b) + .get_account_public(b_id) .await .map_err(ExecutionFailureKind::SequencerError)?; let definition_token_a_id = TokenHolding::try_from(&user_a_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_a))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(a_id))? .definition_id(); let definition_token_b_id = TokenHolding::try_from(&user_b_acc.data) - .map_err(|_err| ExecutionFailureKind::AccountDataError(user_holding_b))? + .map_err(|_err| ExecutionFailureKind::AccountDataError(b_id))? .definition_id(); let amm_pool = @@ -264,9 +292,9 @@ impl Amm<'_> { AccountIdentity::PublicNoSign(vault_holding_a), AccountIdentity::PublicNoSign(vault_holding_b), AccountIdentity::PublicNoSign(pool_lp), - AccountIdentity::Public(user_holding_a), - AccountIdentity::Public(user_holding_b), - AccountIdentity::PublicNoSign(user_holding_lp), + user_holding_a, + user_holding_b, + user_holding_lp, ], instruction_data, &program.into(), @@ -278,7 +306,7 @@ impl Amm<'_> { &self, user_holding_a: AccountId, user_holding_b: AccountId, - user_holding_lp: AccountId, + user_holding_lp: AccountIdentity, remove_liquidity_amount: u128, min_amount_to_remove_token_a: u128, min_amount_to_remove_token_b: u128, @@ -325,7 +353,7 @@ impl Amm<'_> { AccountIdentity::PublicNoSign(pool_lp), AccountIdentity::PublicNoSign(user_holding_a), AccountIdentity::PublicNoSign(user_holding_b), - AccountIdentity::Public(user_holding_lp), + user_holding_lp, ], instruction_data, &program.into(), diff --git a/lez/wallet/src/program_facades/ata.rs b/lez/wallet/src/program_facades/ata.rs index 57b9c83f..e6b8fc29 100644 --- a/lez/wallet/src/program_facades/ata.rs +++ b/lez/wallet/src/program_facades/ata.rs @@ -14,9 +14,13 @@ pub struct Ata<'wallet>(pub &'wallet WalletCore); impl Ata<'_> { pub async fn send_create( &self, - owner_id: AccountId, + owner: AccountIdentity, definition_id: AccountId, ) -> Result { + let owner_id = owner + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::ata(); let ata_program_id = program.id(); let ata_id = get_associated_token_account_id( @@ -30,7 +34,7 @@ impl Ata<'_> { self.0 .send_pub_tx( vec![ - AccountIdentity::Public(owner_id), + owner, AccountIdentity::PublicNoSign(definition_id), AccountIdentity::PublicNoSign(ata_id), ], @@ -42,11 +46,15 @@ impl Ata<'_> { pub async fn send_transfer( &self, - owner_id: AccountId, + owner: AccountIdentity, definition_id: AccountId, recipient_id: AccountId, amount: u128, ) -> Result { + let owner_id = owner + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::ata(); let ata_program_id = program.id(); let sender_ata_id = get_associated_token_account_id( @@ -63,7 +71,7 @@ impl Ata<'_> { self.0 .send_pub_tx( vec![ - AccountIdentity::Public(owner_id), + owner, AccountIdentity::PublicNoSign(sender_ata_id), AccountIdentity::PublicNoSign(recipient_id), ], @@ -75,10 +83,14 @@ impl Ata<'_> { pub async fn send_burn( &self, - owner_id: AccountId, + owner: AccountIdentity, definition_id: AccountId, amount: u128, ) -> Result { + let owner_id = owner + .public_account_id() + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + let program = Program::ata(); let ata_program_id = program.id(); let holder_ata_id = get_associated_token_account_id( @@ -95,7 +107,7 @@ impl Ata<'_> { self.0 .send_pub_tx( vec![ - AccountIdentity::Public(owner_id), + owner, AccountIdentity::PublicNoSign(holder_ata_id), AccountIdentity::PublicNoSign(definition_id), ], diff --git a/lez/wallet/src/program_facades/native_token_transfer/public.rs b/lez/wallet/src/program_facades/native_token_transfer/public.rs index f66d4e57..47cbf2c0 100644 --- a/lez/wallet/src/program_facades/native_token_transfer/public.rs +++ b/lez/wallet/src/program_facades/native_token_transfer/public.rs @@ -1,131 +1,41 @@ use authenticated_transfer_core::Instruction as AuthTransferInstruction; -use common::{HashType, transaction::LeeTransaction}; -use lee::{ - AccountId, PublicTransaction, - program::Program, - public_transaction::{Message, WitnessSet}, -}; -use pyo3::exceptions::PyRuntimeError; -use sequencer_service_rpc::RpcClient as _; +use common::HashType; +use lee::program::Program; use super::NativeTokenTransfer; use crate::{ - ExecutionFailureKind, cli::CliAccountMention, helperfunctions::read_pin, signing::SigningGroups, + AccountIdentity, ExecutionFailureKind, + program_facades::native_token_transfer::auth_transfer_preparation, }; impl NativeTokenTransfer<'_> { pub async fn send_public_transfer( &self, - from: AccountId, - to: AccountId, + from: AccountIdentity, + to: AccountIdentity, balance_to_move: u128, - from_mention: &CliAccountMention, - to_mention: &CliAccountMention, ) -> Result { - let mut groups = SigningGroups::new(); - groups - .add_sender(from_mention, from, self.0) - .and_then(|()| groups.add_recipient(to_mention, to, self.0)) - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::( - e.to_string(), - )) - })?; + let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move); - let program_id = Program::authenticated_transfer_program().id(); - let nonces = self - .0 - .get_accounts_nonces(groups.signing_ids()) + self.0 + .send_pub_tx_with_pre_check( + vec![from, to], + instruction_data, + &program.into(), + tx_pre_check, + ) .await - .map_err(ExecutionFailureKind::SequencerError)?; - - let message = Message::try_new( - program_id, - vec![from, to], - nonces, - AuthTransferInstruction::Transfer { - amount: balance_to_move, - }, - ) - .map_err(ExecutionFailureKind::TransactionBuildError)?; - - let pin = if groups.needs_pin() { - read_pin() - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::( - e.to_string(), - )) - })? - .as_str() - .to_owned() - } else { - String::new() - }; - - let sigs = groups.sign_all(&message.hash(), &pin).map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::(e.to_string())) - })?; - - let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(sigs)); - Ok(self - .0 - .sequencer_client - .send_transaction(LeeTransaction::Public(tx)) - .await?) } pub async fn register_account( &self, - from: AccountId, - account_mention: &CliAccountMention, + account: AccountIdentity, ) -> Result { - let nonces = self - .0 - .get_accounts_nonces(vec![from]) + let program = Program::authenticated_transfer_program(); + let instruction_data = Program::serialize_instruction(AuthTransferInstruction::Initialize)?; + + self.0 + .send_pub_tx(vec![account], instruction_data, &program.into()) .await - .map_err(ExecutionFailureKind::SequencerError)?; - - let account_ids = vec![from]; - let program_id = Program::authenticated_transfer_program().id(); - let message = Message::try_new( - program_id, - account_ids, - nonces, - AuthTransferInstruction::Initialize, - ) - .map_err(ExecutionFailureKind::TransactionBuildError)?; - - let mut groups = SigningGroups::new(); - groups - .add_sender(account_mention, from, self.0) - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::( - e.to_string(), - )) - })?; - - let pin = if groups.needs_pin() { - read_pin() - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::( - e.to_string(), - )) - })? - .as_str() - .to_owned() - } else { - String::new() - }; - - let sigs = groups.sign_all(&message.hash(), &pin).map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::(e.to_string())) - })?; - - let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(sigs)); - Ok(self - .0 - .sequencer_client - .send_transaction(LeeTransaction::Public(tx)) - .await?) } } diff --git a/lez/wallet/src/program_facades/native_token_transfer/shielded.rs b/lez/wallet/src/program_facades/native_token_transfer/shielded.rs index ef074e37..002b1176 100644 --- a/lez/wallet/src/program_facades/native_token_transfer/shielded.rs +++ b/lez/wallet/src/program_facades/native_token_transfer/shielded.rs @@ -8,16 +8,15 @@ use crate::{AccountIdentity, ExecutionFailureKind}; impl NativeTokenTransfer<'_> { pub async fn send_shielded_transfer( &self, - from: AccountId, + from: AccountIdentity, to: AccountId, balance_to_move: u128, ) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> { let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move); - self.0 .send_privacy_preserving_tx_with_pre_check( vec![ - AccountIdentity::Public(from), + from, self.0 .resolve_private_account(to) .ok_or(ExecutionFailureKind::KeyNotFoundError)?, @@ -38,18 +37,17 @@ impl NativeTokenTransfer<'_> { pub async fn send_shielded_transfer_to_outer_account( &self, - from: AccountId, + from: AccountIdentity, to_npk: NullifierPublicKey, to_vpk: ViewingPublicKey, to_identifier: Identifier, balance_to_move: u128, ) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> { let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move); - self.0 .send_privacy_preserving_tx_with_pre_check( vec![ - AccountIdentity::Public(from), + from, AccountIdentity::PrivateForeign { npk: to_npk, vpk: to_vpk, diff --git a/lez/wallet/src/program_facades/token.rs b/lez/wallet/src/program_facades/token.rs index df1b14c6..2170c046 100644 --- a/lez/wallet/src/program_facades/token.rs +++ b/lez/wallet/src/program_facades/token.rs @@ -10,8 +10,8 @@ pub struct Token<'wallet>(pub &'wallet WalletCore); impl Token<'_> { pub async fn send_new_definition( &self, - definition_account_id: AccountId, - supply_account_id: AccountId, + definition: AccountIdentity, + supply: AccountIdentity, name: String, total_supply: u128, ) -> Result { @@ -21,14 +21,7 @@ impl Token<'_> { Program::serialize_instruction(instruction).expect("Instruction should serialize"); self.0 - .send_pub_tx( - vec![ - AccountIdentity::Public(definition_account_id), - AccountIdentity::Public(supply_account_id), - ], - instruction_data, - &program.into(), - ) + .send_pub_tx(vec![definition, supply], instruction_data, &program.into()) .await } @@ -131,8 +124,8 @@ impl Token<'_> { pub async fn send_transfer_transaction( &self, - sender_account_id: AccountId, - recipient_account_id: AccountId, + sender: AccountIdentity, + recipient: AccountIdentity, amount: u128, ) -> Result { let program = Program::token(); @@ -143,14 +136,7 @@ impl Token<'_> { Program::serialize_instruction(instruction).expect("Instruction should serialize"); self.0 - .send_pub_tx( - vec![ - AccountIdentity::Public(sender_account_id), - AccountIdentity::Public(recipient_account_id), - ], - instruction_data, - &program.into(), - ) + .send_pub_tx(vec![sender, recipient], instruction_data, &program.into()) .await } @@ -261,7 +247,7 @@ impl Token<'_> { pub async fn send_transfer_transaction_shielded_owned_account( &self, - sender_account_id: AccountId, + sender: AccountIdentity, recipient_account_id: AccountId, amount: u128, ) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> { @@ -270,11 +256,10 @@ impl Token<'_> { }; let instruction_data = Program::serialize_instruction(instruction).expect("Instruction should serialize"); - self.0 .send_privacy_preserving_tx( vec![ - AccountIdentity::Public(sender_account_id), + sender, self.0 .resolve_private_account(recipient_account_id) .ok_or(ExecutionFailureKind::KeyNotFoundError)?, @@ -294,7 +279,7 @@ impl Token<'_> { pub async fn send_transfer_transaction_shielded_foreign_account( &self, - sender_account_id: AccountId, + sender: AccountIdentity, recipient_npk: NullifierPublicKey, recipient_vpk: ViewingPublicKey, recipient_identifier: Identifier, @@ -305,11 +290,10 @@ impl Token<'_> { }; let instruction_data = Program::serialize_instruction(instruction).expect("Instruction should serialize"); - self.0 .send_privacy_preserving_tx( vec![ - AccountIdentity::Public(sender_account_id), + sender, AccountIdentity::PrivateForeign { npk: recipient_npk, vpk: recipient_vpk, @@ -332,7 +316,7 @@ impl Token<'_> { pub async fn send_burn_transaction( &self, definition_account_id: AccountId, - holder_account_id: AccountId, + holder: AccountIdentity, amount: u128, ) -> Result { let program = Program::token(); @@ -344,10 +328,7 @@ impl Token<'_> { self.0 .send_pub_tx( - vec![ - AccountIdentity::PublicNoSign(definition_account_id), - AccountIdentity::Public(holder_account_id), - ], + vec![AccountIdentity::PublicNoSign(definition_account_id), holder], instruction_data, &program.into(), ) @@ -456,8 +437,8 @@ impl Token<'_> { pub async fn send_mint_transaction( &self, - definition_account_id: AccountId, - holder_account_id: AccountId, + definition: AccountIdentity, + holder: AccountIdentity, amount: u128, ) -> Result { let program = Program::token(); @@ -468,14 +449,7 @@ impl Token<'_> { Program::serialize_instruction(instruction).expect("Instruction should serialize"); self.0 - .send_pub_tx( - vec![ - AccountIdentity::Public(definition_account_id), - AccountIdentity::Public(holder_account_id), - ], - instruction_data, - &program.into(), - ) + .send_pub_tx(vec![definition, holder], instruction_data, &program.into()) .await } diff --git a/lez/wallet/src/signing.rs b/lez/wallet/src/signing.rs index 1431a85a..505dedd9 100644 --- a/lez/wallet/src/signing.rs +++ b/lez/wallet/src/signing.rs @@ -1,114 +1,33 @@ -use anyhow::Result; use keycard_wallet::{KeycardWallet, python_path}; -use lee::{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 SigningGroups { - local: Vec<(AccountId, PrivateKey)>, - keycard: Vec<(AccountId, String)>, +/// Lazily opens and reuses a single Keycard session for all keycard signers in one transaction. +pub struct KeycardSessionContext { + pin: String, + wallet: Option, } -impl SigningGroups { - #[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_sender( - &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(()); +impl KeycardSessionContext { + pub fn new(pin: impl Into) -> Self { + Self { + pin: pin.into(), + wallet: None, } - 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_sender`] 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_recipient( - &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(()); + pub fn get_or_connect(&mut self, py: Python<'_>) -> pyo3::PyResult<&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); } - if let Some(key) = wallet_core - .storage() - .key_chain() - .pub_account_signing_key(account_id) - { - self.local.push((account_id, key.clone())); + Ok(self.wallet.as_ref().expect("wallet was just inserted")) + } + + pub fn close(self, py: Python<'_>) { + if let Some(w) = self.wallet { + let _res = w.close_session(py); } - 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) } }