diff --git a/integration_tests/tests/auth_transfer/build.rs b/integration_tests/tests/auth_transfer/build.rs new file mode 100644 index 00000000..04601cfd --- /dev/null +++ b/integration_tests/tests/auth_transfer/build.rs @@ -0,0 +1,123 @@ +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use authenticated_transfer_core::Instruction as AuthTransferInstruction; +use common::transaction::LeeTransaction; +use integration_tests::{ + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, verify_commitment_is_in_state, +}; +use lee::{privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program}; +use sequencer_service_rpc::RpcClient as _; +use tokio::test; +use wallet::{AccountIdentity, poller::TxPoller}; + +#[test] +async fn public_build_requires_explicit_submission() -> Result<()> { + let mut ctx = TestContext::new().await?; + let (account_id, _) = ctx.wallet_mut().create_new_account_public(None); + let before_build = ctx.sequencer_client().get_account(account_id).await?; + + let transaction = ctx + .wallet() + .build_pub_tx( + vec![AccountIdentity::Public(account_id)], + Program::serialize_instruction(AuthTransferInstruction::Initialize)?, + programs::authenticated_transfer().id(), + ) + .await?; + let transaction = LeeTransaction::Public(transaction); + let transaction_hash = transaction.hash(); + + // Let a sequencer block interval pass: accidental submission would index the transaction. + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + assert!( + ctx.sequencer_client() + .get_transaction(transaction_hash) + .await? + .is_none(), + "build_pub_tx must not submit the transaction" + ); + + let after_build = ctx.sequencer_client().get_account(account_id).await?; + assert_eq!(after_build.balance, before_build.balance); + assert_eq!(after_build.nonce, before_build.nonce); + assert_eq!(after_build.program_owner, before_build.program_owner); + + let hash = ctx.wallet().submit_transaction(transaction).await?; + assert_eq!(hash, transaction_hash); + let (submitted, _) = TxPoller::new(ctx.wallet().config(), ctx.sequencer_client().clone()) + .poll_tx(hash) + .await?; + assert!(matches!(submitted, LeeTransaction::Public(_))); + + let after_submit = ctx.sequencer_client().get_account(account_id).await?; + assert_eq!( + after_submit.program_owner, + programs::authenticated_transfer().id() + ); + assert_eq!(after_submit.nonce.0, before_build.nonce.0 + 1); + + Ok(()) +} + +#[test] +async fn private_build_requires_explicit_submission() -> Result<()> { + let ctx = TestContext::new().await?; + let from = ctx.existing_private_accounts()[0]; + let to = ctx.existing_private_accounts()[1]; + let from_commitment = ctx + .wallet() + .get_private_account_commitment(from) + .context("sender commitment should exist")?; + let to_commitment = ctx + .wallet() + .get_private_account_commitment(to) + .context("recipient commitment should exist")?; + assert!(verify_commitment_is_in_state(from_commitment.clone(), ctx.sequencer_client()).await); + assert!(verify_commitment_is_in_state(to_commitment.clone(), ctx.sequencer_client()).await); + let program: ProgramWithDependencies = programs::authenticated_transfer().into(); + + let (transaction, shared_secrets) = ctx + .wallet() + .build_privacy_preserving_tx( + vec![ + AccountIdentity::PrivateOwned(from), + AccountIdentity::PrivateOwned(to), + ], + Program::serialize_instruction(AuthTransferInstruction::Transfer { amount: 100 })?, + &program, + ) + .await?; + let transaction = LeeTransaction::PrivacyPreserving(transaction); + let transaction_hash = transaction.hash(); + + assert_eq!(shared_secrets.len(), 2); + + // Let a sequencer block interval pass: accidental submission would index the transaction. + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + assert!( + ctx.sequencer_client() + .get_transaction(transaction_hash) + .await? + .is_none(), + "build_privacy_preserving_tx must not submit the transaction" + ); + assert!(verify_commitment_is_in_state(from_commitment, ctx.sequencer_client()).await); + assert!(verify_commitment_is_in_state(to_commitment, ctx.sequencer_client()).await); + + let hash = ctx.wallet().submit_transaction(transaction).await?; + assert_eq!(hash, transaction_hash); + let (submitted, _) = TxPoller::new(ctx.wallet().config(), ctx.sequencer_client().clone()) + .poll_tx(hash) + .await?; + let LeeTransaction::PrivacyPreserving(submitted_transaction) = submitted else { + anyhow::bail!("expected a privacy-preserving transaction"); + }; + + assert_eq!(submitted_transaction.message.new_commitments.len(), 2); + for commitment in submitted_transaction.message.new_commitments { + assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); + } + + Ok(()) +} diff --git a/integration_tests/tests/auth_transfer/main.rs b/integration_tests/tests/auth_transfer/main.rs index 62b8b836..0faa6c7f 100644 --- a/integration_tests/tests/auth_transfer/main.rs +++ b/integration_tests/tests/auth_transfer/main.rs @@ -4,5 +4,6 @@ reason = "We don't care about these in tests" )] +mod build; mod private; mod public; diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index 824a994d..a25e1c8c 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -129,6 +129,31 @@ impl fmt::Debug for AccountIdentity { } impl AccountIdentity { + /// Returns the account ID represented by this identity. + #[must_use] + pub fn account_id(&self) -> AccountId { + match self { + Self::Public(account_id) + | Self::PublicNoSign(account_id) + | Self::PrivateOwned(account_id) + | Self::PrivatePdaOwned(account_id) + | Self::PublicKeycard { account_id, .. } + | Self::PrivatePdaForeign { account_id, .. } + | Self::PrivatePdaShared { account_id, .. } => *account_id, + Self::PrivateForeign { + npk, + vpk, + identifier, + } + | Self::PrivateShared { + npk, + vpk, + identifier, + .. + } => AccountId::from((npk, vpk, *identifier)), + } + } + #[must_use] /// Note: `PublicNoSign` still counts as public, the variant just suppresses the signing-key /// lookup. @@ -665,4 +690,78 @@ mod tests { assert!(acc.is_private()); assert!(!acc.is_public()); } + + #[test] + fn account_id_projects_every_identity_variant() { + let explicit_id = AccountId::new([7; 32]); + let npk = NullifierPublicKey([1; 32]); + let vpk = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]); + let identifier = 42; + let derived_id = AccountId::from((&npk, &vpk, identifier)); + + assert_eq!( + AccountIdentity::Public(explicit_id).account_id(), + explicit_id + ); + assert_eq!( + AccountIdentity::PublicNoSign(explicit_id).account_id(), + explicit_id + ); + assert_eq!( + AccountIdentity::PublicKeycard { + account_id: explicit_id, + key_path: "m/0".to_owned(), + } + .account_id(), + explicit_id + ); + assert_eq!( + AccountIdentity::PrivateOwned(explicit_id).account_id(), + explicit_id + ); + assert_eq!( + AccountIdentity::PrivateForeign { + npk, + vpk: vpk.clone(), + identifier, + } + .account_id(), + derived_id + ); + assert_eq!( + AccountIdentity::PrivatePdaOwned(explicit_id).account_id(), + explicit_id + ); + assert_eq!( + AccountIdentity::PrivatePdaForeign { + account_id: explicit_id, + npk, + vpk: vpk.clone(), + identifier, + } + .account_id(), + explicit_id + ); + assert_eq!( + AccountIdentity::PrivateShared { + nsk: [4; 32], + npk, + vpk: vpk.clone(), + identifier, + } + .account_id(), + derived_id + ); + assert_eq!( + AccountIdentity::PrivatePdaShared { + account_id: explicit_id, + nsk: [4; 32], + npk, + vpk, + identifier, + } + .account_id(), + explicit_id + ); + } } diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index fdd208af..90bf313c 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -16,7 +16,7 @@ use common::{HashType, transaction::LeeTransaction}; use config::WalletConfig; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::{ - Account, AccountId, PrivacyPreservingTransaction, ProgramId, + Account, AccountId, PrivacyPreservingTransaction, ProgramId, PublicTransaction, privacy_preserving_transaction::{ circuit::ProgramWithDependencies, message::{EncryptedAccountData, Message}, @@ -474,6 +474,19 @@ impl WalletCore { Some(Commitment::new(&account_id, account)) } + /// Submit a fully built transaction without polling for its result. + /// + /// # Errors + /// + /// Returns [`ExecutionFailureKind::SequencerClientError`] when the sequencer rejects the + /// submission or cannot be reached. + pub async fn submit_transaction( + &self, + transaction: LeeTransaction, + ) -> Result { + Ok(self.sequencer_client.send_transaction(transaction).await?) + } + pub async fn get_proofs_and_root( &self, commitments: Vec, @@ -563,7 +576,31 @@ impl WalletCore { instruction_data: InstructionData, program: &ProgramWithDependencies, ) -> Result<(HashType, Vec), ExecutionFailureKind> { - self.send_privacy_preserving_tx_with_pre_check(accounts, instruction_data, program, |_| { + let (transaction, shared_secrets) = self + .build_privacy_preserving_tx(accounts, instruction_data, program) + .await?; + let hash = self + .submit_transaction(LeeTransaction::PrivacyPreserving(transaction)) + .await?; + + Ok((hash, shared_secrets)) + } + + /// Build a privacy-preserving transaction without submitting it. + /// + /// Wallet may read account state, prepare private inputs, prove, and sign while building. It + /// does not submit, poll, print, or persist Wallet state. + /// + /// # Errors + /// + /// Returns an [`ExecutionFailureKind`] when account preparation, proving, or signing fails. + pub async fn build_privacy_preserving_tx( + &self, + accounts: Vec, + instruction_data: InstructionData, + program: &ProgramWithDependencies, + ) -> Result<(PrivacyPreservingTransaction, Vec), ExecutionFailureKind> { + self.build_privacy_preserving_tx_with_pre_check(accounts, instruction_data, program, |_| { Ok(()) }) .await @@ -576,6 +613,28 @@ impl WalletCore { program: &ProgramWithDependencies, tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>, ) -> Result<(HashType, Vec), ExecutionFailureKind> { + let (transaction, shared_secrets) = self + .build_privacy_preserving_tx_with_pre_check( + accounts, + instruction_data, + program, + tx_pre_check, + ) + .await?; + let hash = self + .submit_transaction(LeeTransaction::PrivacyPreserving(transaction)) + .await?; + + Ok((hash, shared_secrets)) + } + + async fn build_privacy_preserving_tx_with_pre_check( + &self, + accounts: Vec, + instruction_data: InstructionData, + program: &ProgramWithDependencies, + tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>, + ) -> Result<(PrivacyPreservingTransaction, Vec), ExecutionFailureKind> { let acc_manager = account_manager::AccountManager::new(self, accounts).await?; let pre_states = acc_manager.pre_states(); @@ -620,12 +679,7 @@ impl WalletCore { .map(|keys| keys.ssk) .collect(); - Ok(( - self.sequencer_client - .send_transaction(LeeTransaction::PrivacyPreserving(tx)) - .await?, - shared_secrets, - )) + Ok((tx, shared_secrets)) } pub async fn send_pub_tx( @@ -634,7 +688,28 @@ impl WalletCore { instruction_data: InstructionData, program_id: ProgramId, ) -> Result { - self.send_pub_tx_with_pre_check(accounts, instruction_data, program_id, |_| Ok(())) + let transaction = self + .build_pub_tx(accounts, instruction_data, program_id) + .await?; + self.submit_transaction(LeeTransaction::Public(transaction)) + .await + } + + /// Build a public transaction without submitting it. + /// + /// Wallet may read account state and sign while building. It does not submit, poll, print, + /// or persist Wallet state. + /// + /// # Errors + /// + /// Returns an [`ExecutionFailureKind`] when account preparation or signing fails. + pub async fn build_pub_tx( + &self, + accounts: Vec, + instruction_data: InstructionData, + program_id: ProgramId, + ) -> Result { + self.build_pub_tx_with_pre_check(accounts, instruction_data, program_id, |_| Ok(())) .await } @@ -645,6 +720,20 @@ impl WalletCore { program_id: ProgramId, tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>, ) -> Result { + let transaction = self + .build_pub_tx_with_pre_check(accounts, instruction_data, program_id, tx_pre_check) + .await?; + self.submit_transaction(LeeTransaction::Public(transaction)) + .await + } + + async fn build_pub_tx_with_pre_check( + &self, + accounts: Vec, + instruction_data: InstructionData, + program_id: ProgramId, + tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>, + ) -> Result { // Public transaction, all accounts must be public if accounts.iter().any(AccountIdentity::is_private) { return Err(ExecutionFailureKind::TransactionBuildError( @@ -684,10 +773,7 @@ impl WalletCore { let tx = lee::public_transaction::PublicTransaction::new(message, witness_set); - Ok(self - .sequencer_client - .send_transaction(LeeTransaction::Public(tx)) - .await?) + Ok(tx) } pub async fn sync_to_latest_block(&mut self) -> Result {