diff --git a/.deny.toml b/.deny.toml index ed628f09..e65cdd34 100644 --- a/.deny.toml +++ b/.deny.toml @@ -13,6 +13,7 @@ ignore = [ { id = "RUSTSEC-2025-0055", reason = "`tracing-subscriber` v0.2.25 pulled in by ark-relations v0.4.0 - will be addressed before mainnet" }, { id = "RUSTSEC-2025-0141", reason = "`bincode` is unmaintained but continuing to use it." }, { 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-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" }, ] yanked = "deny" unused-ignored-advisory = "deny" diff --git a/Cargo.lock b/Cargo.lock index a0ac8e67..6492610e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1303,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" dependencies = [ "clap", - "heck", + "heck 0.5.0", "indexmap 2.13.0", "log", "proc-macro2", @@ -1450,7 +1450,7 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1715,15 +1715,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpp_demangle" version = "0.4.5" @@ -2192,7 +2183,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "proc-macro2-diagnostics", ] @@ -3016,6 +3007,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -3569,6 +3566,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3888,7 +3894,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro-crate", "proc-macro2", "quote", @@ -5123,6 +5129,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mempool" version = "0.1.0" @@ -5249,11 +5264,11 @@ dependencies = [ [[package]] name = "multihash" -version = "0.19.3" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" dependencies = [ - "core2", + "no_std_io2", "unsigned-varint", ] @@ -5314,6 +5329,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "no_std_strings" version = "0.1.3" @@ -5388,7 +5412,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6134,7 +6158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6147,7 +6171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6185,6 +6209,69 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "pyo3" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -7132,7 +7219,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 0.26.11", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7143,9 +7230,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "ring", "rustls-pki-types", @@ -7872,7 +7959,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7997,6 +8084,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.26.0" @@ -8845,6 +8938,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -9012,6 +9111,7 @@ dependencies = [ "nssa", "nssa_core", "optfield", + "pyo3", "rand 0.8.5", "sequencer_service_rpc", "serde", @@ -9551,7 +9651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -9562,7 +9662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap 2.13.0", "prettyplease", "syn 2.0.117", diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 775ec45f..29792d9f 100644 Binary files a/artifacts/program_methods/amm.bin and b/artifacts/program_methods/amm.bin differ diff --git a/artifacts/program_methods/associated_token_account.bin b/artifacts/program_methods/associated_token_account.bin index 917e0dc5..94e7f824 100644 Binary files a/artifacts/program_methods/associated_token_account.bin and b/artifacts/program_methods/associated_token_account.bin differ diff --git a/artifacts/program_methods/authenticated_transfer.bin b/artifacts/program_methods/authenticated_transfer.bin index cdce17b9..1541ad33 100644 Binary files a/artifacts/program_methods/authenticated_transfer.bin and b/artifacts/program_methods/authenticated_transfer.bin differ diff --git a/artifacts/program_methods/clock.bin b/artifacts/program_methods/clock.bin index 37a4d30f..732e6868 100644 Binary files a/artifacts/program_methods/clock.bin and b/artifacts/program_methods/clock.bin differ diff --git a/artifacts/program_methods/pinata.bin b/artifacts/program_methods/pinata.bin index e18d5c2c..2e55c190 100644 Binary files a/artifacts/program_methods/pinata.bin and b/artifacts/program_methods/pinata.bin differ diff --git a/artifacts/program_methods/pinata_token.bin b/artifacts/program_methods/pinata_token.bin index f2115a68..1091ff9f 100644 Binary files a/artifacts/program_methods/pinata_token.bin and b/artifacts/program_methods/pinata_token.bin differ diff --git a/artifacts/program_methods/privacy_preserving_circuit.bin b/artifacts/program_methods/privacy_preserving_circuit.bin index 21cf0ddb..7506b118 100644 Binary files a/artifacts/program_methods/privacy_preserving_circuit.bin and b/artifacts/program_methods/privacy_preserving_circuit.bin differ diff --git a/artifacts/program_methods/token.bin b/artifacts/program_methods/token.bin index ebb374f3..b917e392 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index e2fea8bd..c8675c9d 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index d6670787..e8a89862 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index 47c4200e..223862a3 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 8b8bc140..64faf397 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/clock_chain_caller.bin b/artifacts/test_program_methods/clock_chain_caller.bin index 2faa9b69..6789fdc1 100644 Binary files a/artifacts/test_program_methods/clock_chain_caller.bin and b/artifacts/test_program_methods/clock_chain_caller.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index 2ade0385..bf588bc2 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index d0095d2b..9688783e 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/flash_swap_callback.bin b/artifacts/test_program_methods/flash_swap_callback.bin index f259c5b3..080160bb 100644 Binary files a/artifacts/test_program_methods/flash_swap_callback.bin and b/artifacts/test_program_methods/flash_swap_callback.bin differ diff --git a/artifacts/test_program_methods/flash_swap_initiator.bin b/artifacts/test_program_methods/flash_swap_initiator.bin index f1b67504..ee3706ad 100644 Binary files a/artifacts/test_program_methods/flash_swap_initiator.bin and b/artifacts/test_program_methods/flash_swap_initiator.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index 75df8bec..4b93c2f3 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/malicious_caller_program_id.bin b/artifacts/test_program_methods/malicious_caller_program_id.bin index 9907ba58..b449018c 100644 Binary files a/artifacts/test_program_methods/malicious_caller_program_id.bin and b/artifacts/test_program_methods/malicious_caller_program_id.bin differ diff --git a/artifacts/test_program_methods/malicious_self_program_id.bin b/artifacts/test_program_methods/malicious_self_program_id.bin index b530a0b3..bbfa9f9d 100644 Binary files a/artifacts/test_program_methods/malicious_self_program_id.bin and b/artifacts/test_program_methods/malicious_self_program_id.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index 392aa2fa..d0a37e64 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index 92998b57..8f61b773 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index 65475b18..9470b6a0 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index 809ed4ec..87e1eb13 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index 9c2fa8bc..30f82b6a 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin index 36e60f9c..9ad5a3c8 100644 Binary files a/artifacts/test_program_methods/pinata_cooldown.bin and b/artifacts/test_program_methods/pinata_cooldown.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 4dbb34b8..7e24861f 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index df9bee1d..65c574c4 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/artifacts/test_program_methods/time_locked_transfer.bin b/artifacts/test_program_methods/time_locked_transfer.bin index 8b3da3ea..70be21fe 100644 Binary files a/artifacts/test_program_methods/time_locked_transfer.bin and b/artifacts/test_program_methods/time_locked_transfer.bin differ diff --git a/artifacts/test_program_methods/validity_window.bin b/artifacts/test_program_methods/validity_window.bin index 009bb965..0acfd77c 100644 Binary files a/artifacts/test_program_methods/validity_window.bin and b/artifacts/test_program_methods/validity_window.bin differ diff --git a/artifacts/test_program_methods/validity_window_chain_caller.bin b/artifacts/test_program_methods/validity_window_chain_caller.bin index cf9e8af5..3dde4687 100644 Binary files a/artifacts/test_program_methods/validity_window_chain_caller.bin and b/artifacts/test_program_methods/validity_window_chain_caller.bin differ diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index a08fb2b4..bf1ef74f 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -382,8 +382,8 @@ impl ProgramOutput { } /// Representation of a number as `lo + hi * 2^128`. -#[derive(PartialEq, Eq)] -struct WrappedBalanceSum { +#[derive(Debug, PartialEq, Eq)] +pub struct WrappedBalanceSum { lo: u128, hi: u128, } @@ -393,7 +393,7 @@ impl WrappedBalanceSum { /// /// Returns [`None`] if balance sum overflows `lo + hi * 2^128` representation, which is not /// expected in practical scenarios. - fn from_balances(balances: impl Iterator) -> Option { + pub fn from_balances(balances: impl Iterator) -> Option { let mut wrapped = Self { lo: 0, hi: 0 }; for balance in balances { @@ -408,6 +408,75 @@ impl WrappedBalanceSum { } } +impl std::fmt::Display for WrappedBalanceSum { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.hi == 0 { + write!(f, "{}", self.lo) + } else { + write!(f, "{} * 2^128 + {}", self.hi, self.lo) + } + } +} + +impl From for WrappedBalanceSum { + fn from(value: u128) -> Self { + Self { lo: value, hi: 0 } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum ExecutionValidationError { + #[error("Pre-state account IDs are not unique")] + PreStateAccountIdsNotUnique, + + #[error( + "Pre-state and post-state lengths do not match: pre-state length {pre_state_length}, post-state length {post_state_length}" + )] + MismatchedPreStatePostStateLength { + pre_state_length: usize, + post_state_length: usize, + }, + + #[error("Unallowed modification of nonce for account {account_id}")] + ModifiedNonce { account_id: AccountId }, + + #[error("Unallowed modification of program owner for account {account_id}")] + ModifiedProgramOwner { account_id: AccountId }, + + #[error( + "Trying to decrease balance of account {account_id} owned by {owner_program_id:?} in a program {executing_program_id:?} which is not the owner" + )] + UnauthorizedBalanceDecrease { + account_id: AccountId, + owner_program_id: ProgramId, + executing_program_id: ProgramId, + }, + + #[error( + "Unauthorized modification of data for account {account_id} which is not default and not owned by executing program {executing_program_id:?}" + )] + UnauthorizedDataModification { + account_id: AccountId, + executing_program_id: ProgramId, + }, + + #[error( + "Post-state for account {account_id} has default program owner but pre-state was not default" + )] + NonDefaultAccountWithDefaultOwner { account_id: AccountId }, + + #[error("Total balance across accounts overflowed 2^256 - 1")] + BalanceSumOverflow, + + #[error( + "Total balance across accounts is not preserved: total balance in pre-states {total_balance_pre_states}, total balance in post-states {total_balance_post_states}" + )] + MismatchedTotalBalance { + total_balance_pre_states: WrappedBalanceSum, + total_balance_post_states: WrappedBalanceSum, + }, +} + #[must_use] pub fn compute_authorized_pdas( caller_program_id: Option, @@ -448,31 +517,39 @@ pub fn read_nssa_inputs() -> (ProgramInput, InstructionD /// - `pre_states`: The list of input accounts, each annotated with authorization metadata. /// - `post_states`: The list of resulting accounts after executing the program logic. /// - `executing_program_id`: The identifier of the program that was executed. -#[must_use] pub fn validate_execution( pre_states: &[AccountWithMetadata], post_states: &[AccountPostState], executing_program_id: ProgramId, -) -> bool { +) -> Result<(), ExecutionValidationError> { // 1. Check account ids are all different if !validate_uniqueness_of_account_ids(pre_states) { - return false; + return Err(ExecutionValidationError::PreStateAccountIdsNotUnique); } // 2. Lengths must match if pre_states.len() != post_states.len() { - return false; + return Err( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length: pre_states.len(), + post_state_length: post_states.len(), + }, + ); } for (pre, post) in pre_states.iter().zip(post_states) { // 3. Nonce must remain unchanged if pre.account.nonce != post.account.nonce { - return false; + return Err(ExecutionValidationError::ModifiedNonce { + account_id: pre.account_id, + }); } // 4. Program ownership changes are not allowed if pre.account.program_owner != post.account.program_owner { - return false; + return Err(ExecutionValidationError::ModifiedProgramOwner { + account_id: pre.account_id, + }); } let account_program_owner = pre.account.program_owner; @@ -481,7 +558,11 @@ pub fn validate_execution( if post.account.balance < pre.account.balance && account_program_owner != executing_program_id { - return false; + return Err(ExecutionValidationError::UnauthorizedBalanceDecrease { + account_id: pre.account_id, + owner_program_id: account_program_owner, + executing_program_id, + }); } // 6. Data changes only allowed if owned by executing program or if account pre state has @@ -490,13 +571,20 @@ pub fn validate_execution( && pre.account != Account::default() && account_program_owner != executing_program_id { - return false; + return Err(ExecutionValidationError::UnauthorizedDataModification { + account_id: pre.account_id, + executing_program_id, + }); } // 7. If a post state has default program owner, the pre state must have been a default // account if post.account.program_owner == DEFAULT_PROGRAM_ID && pre.account != Account::default() { - return false; + return Err( + ExecutionValidationError::NonDefaultAccountWithDefaultOwner { + account_id: pre.account_id, + }, + ); } } @@ -505,20 +593,23 @@ pub fn validate_execution( let Some(total_balance_pre_states) = WrappedBalanceSum::from_balances(pre_states.iter().map(|pre| pre.account.balance)) else { - return false; + return Err(ExecutionValidationError::BalanceSumOverflow); }; let Some(total_balance_post_states) = WrappedBalanceSum::from_balances(post_states.iter().map(|post| post.account.balance)) else { - return false; + return Err(ExecutionValidationError::BalanceSumOverflow); }; if total_balance_pre_states != total_balance_post_states { - return false; + return Err(ExecutionValidationError::MismatchedTotalBalance { + total_balance_pre_states, + total_balance_post_states, + }); } - true + Ok(()) } fn validate_uniqueness_of_account_ids(pre_states: &[AccountWithMetadata]) -> bool { diff --git a/nssa/src/error.rs b/nssa/src/error.rs index 61966515..565e02ba 100644 --- a/nssa/src/error.rs +++ b/nssa/src/error.rs @@ -1,12 +1,16 @@ use std::io; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; use thiserror::Error; #[macro_export] macro_rules! ensure { ($cond:expr, $err:expr) => { if !$cond { - return Err($err); + return Err($err.into()); } }; } @@ -17,7 +21,7 @@ pub enum NssaError { InvalidInput(String), #[error("Program violated execution rules")] - InvalidProgramBehavior, + InvalidProgramBehavior(#[from] InvalidProgramBehaviorError), #[error("Serialization error: {0}")] InstructionSerializationError(String), @@ -32,15 +36,15 @@ pub enum NssaError { InvalidPublicKey(#[source] k256::schnorr::Error), #[error("Invalid hex for public key")] - InvalidHexPublicKey(hex::FromHexError), + InvalidHexPublicKey(#[source] hex::FromHexError), - #[error("Risc0 error: {0}")] + #[error("Failed to write program input: {0}")] ProgramWriteInputFailed(String), - #[error("Risc0 error: {0}")] + #[error("Failed to execute program: {0}")] ProgramExecutionFailed(String), - #[error("Risc0 error: {0}")] + #[error("Failed to prove program: {0}")] ProgramProveFailed(String), #[error("Invalid transaction: {0}")] @@ -77,6 +81,61 @@ pub enum NssaError { OutOfValidityWindow, } +#[derive(Error, Debug)] +pub enum InvalidProgramBehaviorError { + #[error( + "Inconsistent pre-state for account {account_id} : expected {expected:?}, actual {actual:?}" + )] + InconsistentAccountPreState { + account_id: AccountId, + // Boxed to reduce the size of the error type + expected: Box, + actual: Box, + }, + + #[error( + "Inconsistent authorization for account {account_id} : expected {expected_authorization}, actual {actual_authorization}" + )] + InconsistentAccountAuthorization { + account_id: AccountId, + expected_authorization: bool, + actual_authorization: bool, + }, + + #[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedProgramId { + expected: ProgramId, + actual: ProgramId, + }, + + #[error("Caller program ID mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedCallerProgramId { + expected: Option, + actual: Option, + }, + + #[error(transparent)] + ExecutionValidationFailed(#[from] nssa_core::program::ExecutionValidationError), + + #[error("Trying to claim account {account_id} which is not default")] + ClaimedNonDefaultAccount { account_id: AccountId }, + + #[error("Trying to claim account {account_id} which is not authorized")] + ClaimedUnauthorizedAccount { account_id: AccountId }, + + #[error("PDA claim mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedPdaClaim { + expected: AccountId, + actual: AccountId, + }, + + #[error("Default account {account_id} was modified without being claimed")] + DefaultAccountModifiedWithoutClaim { account_id: AccountId }, + + #[error("Called program {program_id:?} which is not listed in dependencies")] + UndeclaredProgramDependency { program_id: ProgramId }, +} + #[cfg(test)] mod tests { diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index 6c174450..528bb372 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -10,7 +10,7 @@ use nssa_core::{ use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; use crate::{ - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, program::Program, program_methods::{PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID}, state::MAX_NUMBER_CHAINED_CALLS, @@ -113,9 +113,11 @@ pub fn execute_and_prove( env_builder.add_assumption(inner_receipt); for new_call in program_output.chained_calls.into_iter().rev() { - let next_program = dependencies - .get(&new_call.program_id) - .ok_or(NssaError::InvalidProgramBehavior)?; + let next_program = dependencies.get(&new_call.program_id).ok_or( + InvalidProgramBehaviorError::UndeclaredProgramDependency { + program_id: new_call.program_id, + }, + )?; chained_calls.push_front((new_call, next_program, Some(chained_call.program_id))); } diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 17abc6d1..40cd814e 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -366,12 +366,15 @@ pub mod tests { Timestamp, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey}, - program::{BlockValidityWindow, PdaSeed, ProgramId, TimestampValidityWindow}, + program::{ + BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId, + TimestampValidityWindow, WrappedBalanceSum, + }, }; use crate::{ PublicKey, PublicTransaction, V03State, - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, execute_and_prove, privacy_preserving_transaction::{ PrivacyPreservingTransaction, @@ -933,10 +936,11 @@ pub mod tests { #[test] fn program_should_fail_if_modifies_nonces() { - let initial_data = [(AccountId::new([1; 32]), 100)]; + let account_id = AccountId::new([1; 32]); + let initial_data = [(account_id, 100)]; let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); - let account_ids = vec![AccountId::new([1; 32])]; + let account_ids = vec![account_id]; let program_id = Program::nonce_changer_program().id(); let message = public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); @@ -945,7 +949,14 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedNonce { account_id: err_account_id } + ) + )) if err_account_id == account_id + )); } #[test] @@ -962,7 +973,17 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length, + post_state_length + } + ) + )) if pre_state_length == 1 && post_state_length == 2 + )); } #[test] @@ -979,7 +1000,17 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length, + post_state_length + } + ) + )) if pre_state_length == 2 && post_state_length == 1 + )); } #[test] @@ -1003,7 +1034,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] @@ -1027,7 +1063,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] @@ -1051,7 +1092,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] @@ -1075,16 +1121,21 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] fn program_should_fail_if_transfers_balance_from_non_owned_account() { - let initial_data = [(AccountId::new([1; 32]), 100)]; - let mut state = - V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let sender_account_id = AccountId::new([1; 32]); let receiver_account_id = AccountId::new([2; 32]); + let initial_data = [(sender_account_id, 100)]; + let mut state = + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let balance_to_move: u128 = 1; let program_id = Program::simple_balance_transfer().id(); assert_ne!( @@ -1103,7 +1154,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id } + ))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id + )); } #[test] @@ -1128,7 +1184,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id } + ))) if err_account_id == account_id && executing_program_id == program_id + )); } #[test] @@ -1146,7 +1207,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into() + )); } #[test] @@ -1175,7 +1241,12 @@ pub mod tests { let tx = PublicTransaction::new(message, witness_set); let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into() + )); } fn test_public_account_keys_1() -> TestPublicKeys { @@ -3088,7 +3159,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id } + )) if err_account_id == account_id + )); } /// This test ensures that even if a malicious program tries to perform overflow of balances @@ -3134,7 +3210,22 @@ pub mod tests { let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]); let tx = PublicTransaction::new(message, witness_set); let res = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(res, Err(NssaError::InvalidProgramBehavior))); + let expected_total_balance_pre_states = WrappedBalanceSum::from_balances( + [sender_init_balance, recipient_init_balance].into_iter(), + ) + .unwrap(); + let expected_total_balance_post_states = WrappedBalanceSum::from_balances( + [sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(), + ) + .unwrap(); + assert!(matches!( + res, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ) + )) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states + )); let sender_post = state.get_account_by_id(sender_id); let recipient_post = state.get_account_by_id(recipient_id); @@ -3379,7 +3470,14 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); // Should fail - cannot modify data without claiming the account - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { + account_id: err_account_id + } + )) if err_account_id == account_id + )); } #[test] diff --git a/nssa/src/validated_state_diff.rs b/nssa/src/validated_state_diff.rs index 9614d1b7..b4c62a3e 100644 --- a/nssa/src/validated_state_diff.rs +++ b/nssa/src/validated_state_diff.rs @@ -14,7 +14,7 @@ use nssa_core::{ use crate::{ V03State, ensure, - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, privacy_preserving_transaction::{ PrivacyPreservingTransaction, circuit::Proof, message::Message, }, @@ -145,39 +145,52 @@ impl ValidatedStateDiff { .unwrap_or_else(|| state.get_account_by_id(account_id)); ensure!( pre.account == expected_pre, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::InconsistentAccountPreState { + account_id, + expected: Box::new(expected_pre), + actual: Box::new(pre.account.clone()) + } ); // Check that authorization flags are consistent with the provided ones or // authorized by program through the PDA mechanism + let expected_is_authorized = is_authorized(&account_id); ensure!( - pre.is_authorized == is_authorized(&account_id), - NssaError::InvalidProgramBehavior + pre.is_authorized == expected_is_authorized, + InvalidProgramBehaviorError::InconsistentAccountAuthorization { + account_id, + expected_authorization: expected_is_authorized, + actual_authorization: pre.is_authorized + } ); } // Verify that the program output's self_program_id matches the expected program ID. ensure!( program_output.self_program_id == chained_call.program_id, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::MismatchedProgramId { + expected: chained_call.program_id, + actual: program_output.self_program_id + } ); // Verify that the program output's caller_program_id matches the actual caller. ensure!( program_output.caller_program_id == caller_program_id, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::MismatchedCallerProgramId { + expected: caller_program_id, + actual: program_output.caller_program_id, + } ); // Verify execution corresponds to a well-behaved program. // See the # Programs section for the definition of the `validate_execution` method. - ensure!( - validate_execution( - &program_output.pre_states, - &program_output.post_states, - chained_call.program_id, - ), - NssaError::InvalidProgramBehavior - ); + validate_execution( + &program_output.pre_states, + &program_output.post_states, + chained_call.program_id, + ) + .map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?; // Verify validity window ensure!( @@ -192,27 +205,33 @@ impl ValidatedStateDiff { let Some(claim) = post.required_claim() else { continue; }; + let account_id = program_output.pre_states[i].account_id; + // The invoked program can only claim accounts with default program id. ensure!( post.account().program_owner == DEFAULT_PROGRAM_ID, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id } ); - let account_id = program_output.pre_states[i].account_id; - match claim { Claim::Authorized => { // The program can only claim accounts that were authorized by the signer. ensure!( is_authorized(&account_id), - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id } ); } Claim::Pda(seed) => { // The program can only claim accounts that correspond to the PDAs it is // authorized to claim. let pda = AccountId::from((&chained_call.program_id, &seed)); - ensure!(account_id == pda, NssaError::InvalidProgramBehavior); + ensure!( + account_id == pda, + InvalidProgramBehaviorError::MismatchedPdaClaim { + expected: pda, + actual: account_id + } + ); } } @@ -238,7 +257,7 @@ impl ValidatedStateDiff { } // Check that all modified uninitialized accounts where claimed - for post in state_diff.iter().filter_map(|(account_id, post)| { + for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| { let pre = state.get_account_by_id(*account_id); if pre.program_owner != DEFAULT_PROGRAM_ID { return None; @@ -246,11 +265,11 @@ impl ValidatedStateDiff { if pre == *post { return None; } - Some(post) + Some((*account_id, post)) }) { ensure!( post.program_owner != DEFAULT_PROGRAM_ID, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id } ); } diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs index 1d091e1c..16ba91ff 100644 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -125,12 +125,17 @@ impl ExecutionState { // Check that the program is well behaved. // See the # Programs section for the definition of the `validate_execution` method. - let execution_valid = validate_execution( + let validated_execution = validate_execution( &program_output.pre_states, &program_output.post_states, chained_call.program_id, ); - assert!(execution_valid, "Bad behaved program"); + if let Err(err) = validated_execution { + panic!( + "Invalid program behavior in program {:?}: {err}", + chained_call.program_id + ); + } for next_call in program_output.chained_calls.iter().rev() { chained_calls.push_front((next_call.clone(), Some(chained_call.program_id))); diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 4e98b8ef..4edb4523 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -18,6 +18,7 @@ amm_core.workspace = true testnet_initial_state.workspace = true ata_core.workspace = true bip39.workspace = true +pyo3 = { version = "0.21", features = ["auto-initialize"] } anyhow.workspace = true thiserror.workspace = true diff --git a/wallet/src/cli/keycard.rs b/wallet/src/cli/keycard.rs new file mode 100644 index 00000000..2ad75002 --- /dev/null +++ b/wallet/src/cli/keycard.rs @@ -0,0 +1,199 @@ +use anyhow::Result; +use clap::Subcommand; +use pyo3::prelude::*; + +use crate::{ + WalletCore, + cli::{SubcommandReturnValue, WalletSubcommand, keycard_wallet::KeycardWallet, python_path}, +}; + +/// Represents generic chain CLI subcommand. +#[derive(Subcommand, Debug, Clone)] +pub enum KeycardSubcommand { + Available, + Connect { + #[arg( + short, + long, + )] + pin: Option, + }, + Load { + #[arg( + short, + long, + )] + mnemonic: Option, + }, + Remove, +} + +/// Represents generic register CLI subcommand. +/* +#[derive(Subcommand, Debug, Clone)] +pub enum NewSubcommand { + /// Register new public account. + Public { + #[arg(long)] + /// Chain index of a parent node. + cci: Option, + #[arg(short, long)] + /// Label to assign to the new account. + label: Option, + }, + /// Register new private account. + Private { + #[arg(long)] + /// Chain index of a parent node. + cci: Option, + #[arg(short, long)] + /// Label to assign to the new account. + label: Option, + }, +} +*/ +/* +impl WalletSubcommand for NewSubcommand { + async fn handle_subcommand( + self, + wallet_core: &mut WalletCore, + ) -> Result { + match self { + Self::Public { cci, label } => { + if let Some(label) = &label + && wallet_core + .storage + .labels + .values() + .any(|l| l.to_string() == *label) + { + anyhow::bail!("Label '{label}' is already in use by another account"); + } + + let (account_id, chain_index) = wallet_core.create_new_account_public(cci); + + let private_key = wallet_core + .storage + .user_data + .get_pub_account_signing_key(account_id) + .unwrap(); + + let public_key = PublicKey::new_from_private_key(private_key); + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + println!( + "Generated new account with account_id Public/{account_id} at path {chain_index}" + ); + println!("With pk {}", hex::encode(public_key.value())); + + wallet_core.store_persistent_data().await?; + + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } + Self::Private { cci, label } => { + if let Some(label) = &label + && wallet_core + .storage + .labels + .values() + .any(|l| l.to_string() == *label) + { + anyhow::bail!("Label '{label}' is already in use by another account"); + } + + let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + let (key, _) = wallet_core + .storage + .user_data + .get_private_account(account_id) + .unwrap(); + + println!( + "Generated new account with account_id Private/{account_id} at path {chain_index}", + ); + println!("With npk {}", hex::encode(key.nullifier_public_key.0)); + println!( + "With vpk {}", + hex::encode(key.viewing_public_key.to_bytes()) + ); + + wallet_core.store_persistent_data().await?; + + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } + } + } +} + */ + +impl WalletSubcommand for KeycardSubcommand { + #[expect(clippy::cognitive_complexity, reason = "TODO: fix later")] + async fn handle_subcommand( + self, + _wallet_core: &mut WalletCore, + ) -> Result { + match self { + Self::Available => { + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py).expect("Expect keycard wallet"); + + let _available = wallet.is_unpaired_keycard_available(py); + }); + + Ok(SubcommandReturnValue::Empty) + }, + Self::Connect { pin } => { + // TODO This should be persistent. + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py).expect("Expect keycard wallet"); + + let _ = wallet.setup_communication(py, pin.expect("TODO")); + }); + + Ok(SubcommandReturnValue::Empty) + }, + Self::Load { mnemonic } => { + // TODO This should be persistent. + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py).expect("Expect keycard wallet"); + + let _ = wallet.load_account_keys(py, &mnemonic.expect("TODO")); + }); + + Ok(SubcommandReturnValue::Empty) + }, + Self::Remove => { + // TODO This should be persistent. + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py).expect("Expect keycard wallet"); + + let _ = wallet.remove_account_keys(py); + }); + + Ok(SubcommandReturnValue::Empty) + }, + } + } +} diff --git a/wallet/src/cli/keycard_wallet.rs b/wallet/src/cli/keycard_wallet.rs new file mode 100644 index 00000000..20590d25 --- /dev/null +++ b/wallet/src/cli/keycard_wallet.rs @@ -0,0 +1,123 @@ +use pyo3::prelude::*; +use pyo3::types::PyAny; + +/// Rust wrapper around the Python KeycardWallet class. +/// Holds a persistent Python object in memory. +pub struct KeycardWallet { + instance: Py, +} + +impl KeycardWallet { + /// Create a new Python KeycardWallet instance + pub fn new(py: Python) -> PyResult { + let module = py.import_bound("keycard_wallet")?; + let class = module.getattr("KeycardWallet")?; + + let instance = class.call0()?; + + Ok(Self { + instance: instance.into_py(py), + }) + } + + /// Calls Python: is_unpaired_keycard_available() + pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult { + self.instance + .bind(py) // replaces as_ref(py) + .call_method0("is_unpaired_keycard_available")? + .extract() + } + + pub fn setup_communication(&self, py: Python, pin: String) -> PyResult { + let py_pin = pyo3::types::PyString::new_bound(py, &pin); + + self.instance + .bind(py) + .call_method1("setup_communication", (py_pin,))? + .extract() + } + + pub fn disconnect(&self, py: Python) -> PyResult { + self.instance + .bind(py) + .call_method0("disconnect")? + .extract() + } + + pub fn get_public_signing_key(&self, py: Python) -> PyResult<[u8; 32]> { + self.instance + .bind(py) + .call_method0("get_public_signing_key")? + .extract() + } + + pub fn derive_path(&self, py: Python, path: Vec) -> PyResult<()> { + let path = Self::convert_path_to_string(path); + + self.instance + .bind(py) + .call_method1("change_path", (path,))?; + Ok(()) + } + + fn convert_path_to_string(path: Vec) -> String { + format!( + "m/{}", + path.iter() + .map(|n| n.to_string()) + .collect::>() + .join("'/") + ) + } + + pub fn sign_message_current_key(&self, py: Python, message: &[u8; 32]) -> PyResult<[u8; 64]> { + let py_message = pyo3::types::PyBytes::new_bound(py, message); + + let py_signature: Vec = self.instance + .bind(py) + .call_method1("sign_message_current_key", (py_message,))? + .getattr("signature")? // or "bytes", "data", "value", etc. + .extract()?; + + let signature: [u8; 64] = py_signature + .try_into() + .map_err(|_| PyErr::new::( + "Expected signature of exactly 64 bytes" + ))?; + + Ok(signature) + } + + pub fn sign_message_with_path(&self, py: Python, path: Vec, message: &[u8; 32]) -> PyResult<[u8; 64]> { + let py_message = pyo3::types::PyBytes::new_bound(py, message); + let path = Self::convert_path_to_string(path); + + let py_signature: Vec = self.instance + .bind(py) + .call_method1("sign_message_with_path", (path, py_message))? + .getattr("signature")? // or "bytes", "data", "value", etc. + .extract()?; + + let signature: [u8; 64] = py_signature + .try_into() + .map_err(|_| PyErr::new::( + "Expected signature of exactly 64 bytes" + ))?; + + Ok(signature) + } + + pub fn remove_account_keys(&self, py: Python) -> PyResult<()> { + self.instance + .bind(py) + .call_method0("remove_account_keys")?; + Ok(()) + } + + pub fn load_account_keys(&self, py: Python, mnemonic: &str) -> PyResult<()> { + self.instance + .bind(py) + .call_method1("load_account_keys", (mnemonic,))?; + Ok(()) + } +} \ No newline at end of file diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 1653e938..362b9aab 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -14,6 +14,7 @@ use crate::{ account::AccountSubcommand, chain::ChainSubcommand, config::ConfigSubcommand, + keycard::KeycardSubcommand, programs::{ amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, @@ -25,7 +26,10 @@ use crate::{ pub mod account; pub mod chain; pub mod config; +pub mod keycard; pub mod programs; +pub mod python_path; +pub mod keycard_wallet; pub(crate) trait WalletSubcommand { async fn handle_subcommand(self, wallet_core: &mut WalletCore) @@ -73,6 +77,8 @@ pub enum Command { }, /// Deploy a program. DeployProgram { binary_filepath: PathBuf }, + #[command(subcommand)] + Keycard(KeycardSubcommand), } /// To execute commands, env var `NSSA_WALLET_HOME_DIR` must be set into directory with config. @@ -121,6 +127,9 @@ pub async fn execute_subcommand( Command::Pinata(pinata_subcommand) => { pinata_subcommand.handle_subcommand(wallet_core).await? } + Command::Keycard(keycard_subcommand) => { + keycard_subcommand.handle_subcommand(wallet_core).await? + } Command::CheckHealth => { let remote_program_ids = wallet_core .sequencer_client diff --git a/wallet/src/cli/python/keycard_testing.py b/wallet/src/cli/python/keycard_testing.py new file mode 100644 index 00000000..b7c72295 --- /dev/null +++ b/wallet/src/cli/python/keycard_testing.py @@ -0,0 +1,43 @@ +import keycard_wallet as keycard_wallet +import time # For testing + + + +my_wallet = keycard_wallet.KeycardWallet() +print("Setup communication with card...", my_wallet.setup_communication()) + +#my_wallet.load_account_keys() + +#pub_key = my_wallet.get_public_signing_key() + +# TODO: now I want to specify LEE +#print(f"Public key: {list(pub_key)}") + +#my_wallet.debug_key_export() +#priv_key = my_wallet.get_private_signing_key() +#print(f"Private key: {list(priv_key)}") + +#my_wallet.remove_account_keys() + +print("Disconnection", my_wallet.disconnect()) # To not do a stupid + +""" +my_wallet.setup_communication() + + + +# TODO: issues here +#priv_key = my_wallet.get_private_key() +#print(f"Private key: {list(priv_key)}") + + +#signature = my_wallet.sign_message_current_key() +#print(f"Signature: {signature.signature.hex()}") + +#signature = my_wallet.sign_message_with_path("m/44'/60'/0'/0/1") +#print(f"Signature: {signature.signature.hex()}") + +my_wallet.disconnect() # To not do a stupid +""" + + diff --git a/wallet/src/cli/python/keycard_wallet.py b/wallet/src/cli/python/keycard_wallet.py new file mode 100644 index 00000000..146cba00 --- /dev/null +++ b/wallet/src/cli/python/keycard_wallet.py @@ -0,0 +1,189 @@ +from smartcard.System import readers +from keycard.exceptions import APDUError, TransportError +from ecdsa import VerifyingKey, SECP256k1 + +from keycard.keycard import KeyCard + +from mnemonic import Mnemonic +from keycard import constants + +import keycard + +PIN = '123456' +DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" + +class KeycardWallet: + def __init__(self): + self.card = KeyCard() + self.pairing_index = None + self.pairing_key = None + + def _is_smart_card_reader_detected(self) -> bool: + try: + return len(readers()) > 0 + except Exception: + return False + + def _is_keycard_detected(self) -> bool: + try: + KeyCard().select() + return True + except (TransportError, APDUError, Exception): + # No readers, no card, or card doesn't respond. + return False + + # Wrapped + def is_unpaired_keycard_available(self) -> bool: + if not self._is_smart_card_reader_detected(): + return False + elif not self._is_keycard_detected(): + return False + return True + + # Wrapped + def setup_communication(self, pin = PIN, password = DEFAULT_PAIRING_PASSWORD) -> bool: + try: + self.card.select() + + if not self.card.is_initialized: + # TODO: need to be able to initialize a card. + return False + + if self.pairing_index is None: + pairing_index, pairing_key = self.card.pair(password) #Testing + self.pairing_index = pairing_index + self.pairing_key = pairing_key + + self.card.open_secure_channel(pairing_index, pairing_key) + self.card.verify_pin(PIN) + return True + except Exception as e: + print(f"Error: {e}") + return False + + """ + # Needs to be more robust to handle card removal and reinsertion + def is_selected_card_available(self) -> bool: + if self.transport.connection is None: + return False + + try: + #TODO: fix this up Try a lightweight operation + # Card is present + self.card.send_apdu(cla=0x00, ins=0xA4, p1=0x04, p2=0x00, data=b'') + # return True + except Exception: + return False + + # TODO: attempt to prevent a new card from being inserted + return self.card.is_selected + + """ + + # Wrapped + def disconnect(self) -> bool: + try: + self.card.unpair(self.pairing_index) + self.pairing_index = None + self.pairing_key = None + return True + except Exception as e: + print(f"Error during disconnect: {e}") + return False + + # TODO: add path? + # Wrapped + def get_public_signing_key(self): + uncompressed_pub_key = self.card.export_current_key(public_only=True).public_key + + # Convert to VerifyingKey object + vk = VerifyingKey.from_string(uncompressed_pub_key, curve=SECP256k1) + + return vk.to_string("compressed")[1:] + + """ + # TODO: don't think this possible; blocked by firmware + def get_private_signing_key(self): + try: + exported = self.card.export_current_key(public_only=False) + print(f"Exported key: {exported}") + print(f"Public key: {exported.public_key.hex() if exported.public_key else 'None'}") + print(f"Private key: {exported.private_key.hex() if exported.private_key else 'None'}") + print(f"Chain code: {exported.chain_code.hex() if exported.chain_code else 'None'}") + + if exported.private_key is None: + raise ValueError("No private key returned - key may not be loaded on card") + + return exported.private_key + except Exception as e: + print(f"Error exporting key: {e}") + raise + """ + # TODO: delete this function + def debug_key_export(self): + """Debug why key export fails with SW=6985""" + + # 1. Check if a key exists + try: + status = self.card.status + print(f"Status: {status}") + except Exception as e: + print(f"Cannot get status: {e}") + + # 2. Try public key export first + try: + exported = self.card.export_current_key(public_only=True) + print(f"Public key export: {exported.public_key.hex() if exported.public_key else 'None'}") + except Exception as e: + print(f"Public key export failed: {e}") + + # 3. Check if key needs to be generated + try: + key_uid = self. card.generate_key() + print(f"Generated key UID: {key_uid.hex()}") + except Exception as e: + print(f"Key generation failed: {e}") + + # 4. Try private export again + try: + exported = self.card.export_current_key(public_only=False) + if exported.private_key: + print(f"Private key: {exported.private_key.hex()}") + else: + print("Private key is None - key may not allow export") + except Exception as e: + print(f"Private key export failed: {e}") + + #TODO: check well formed? + # Wrapped + def change_path(self, path): + self.card.derive_key(path) + + # Message must be 32 bytes + # TODO: rename to current_path + # Wrapped + def sign_message_current_key(self, message = b"TestMessageMustBe32Bytes!\x00\x00\x00\x00\x00\x00\x00"): + # Message must be sent bytes + return self.card.sign(message, constants.SigningAlgorithm.SCHNORR_BIP340) + + + # Does not update the path + # Wrapped + def sign_message_with_path(self, path, message = b"TestMessageMustBe32Bytes!\x00\x00\x00\x00\x00\x00\x00"): + # must be sent bytes + return self.card.sign_with_path(message, path, False, constants.SigningAlgorithm.SCHNORR_BIP340) + + # Wrapped + def remove_account_keys(self): + self.card.remove_key() + + # TODO: update to accept a different language? + def load_account_keys(self, mnemonic) : + mnemo = Mnemonic("english") + seed = mnemo.to_seed(mnemonic, passphrase="") + + # Load the seed onto the card + result = self.card.load_key( + key_type= constants.LoadKeyType.BIP39_SEED, + lee_seed=seed + ) \ No newline at end of file diff --git a/wallet/src/cli/python_path.rs b/wallet/src/cli/python_path.rs new file mode 100644 index 00000000..62a399ba --- /dev/null +++ b/wallet/src/cli/python_path.rs @@ -0,0 +1,39 @@ +use std::env; +use std::path::PathBuf; +use pyo3::prelude::*; +use pyo3::types::PyList; + +/// Adds the project's `python/` directory and venv site-packages to Python's sys.path +pub fn add_python_path(py: Python) -> PyResult<()> { + let current_dir = env::current_dir() + .expect("Failed to get current working directory"); + + let paths_to_add: Vec = vec![ + current_dir.join("python"), + current_dir.join("python").join("keycard-py-lee-schnorr"), // ← add this + //current_dir.join("python").join("keycard-py-lee-schnorr").join(".venv").join("Lib").join("site-packages"), + ]; + + // Sanity check — warns early if a path doesn't exist + for path in &paths_to_add { + if !path.exists() { + eprintln!("Warning: Python path does not exist: {:?}", path); + } + } + + let sys = py.import_bound("sys")?; + let sys_path: &PyList = sys.getattr("path")?.extract()?; + + for path in &paths_to_add { + let path_str = path + .to_str() + .expect("Invalid path"); + + // Avoid duplicating the path + if !sys_path.iter().any(|p| p.extract::<&str>().unwrap_or("") == path_str) { + sys_path.insert(0, path_str)?; + } + } + + Ok(()) +} \ No newline at end of file