Merge 7a40979c7f5b04a46a8665a3d07cd3a300dcae63 into 390f4b48d982511883eec8628a9742976f8de8d3

This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-16 11:21:42 -03:00 committed by GitHub
commit 9d77b6f59e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 322 additions and 13 deletions

View File

@ -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(())
}

View File

@ -4,5 +4,6 @@
reason = "We don't care about these in tests"
)]
mod build;
mod private;
mod public;

View File

@ -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
);
}
}

View File

@ -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<HashType, ExecutionFailureKind> {
Ok(self.sequencer_client.send_transaction(transaction).await?)
}
pub async fn get_proofs_and_root(
&self,
commitments: Vec<Commitment>,
@ -563,7 +576,31 @@ impl WalletCore {
instruction_data: InstructionData,
program: &ProgramWithDependencies,
) -> Result<(HashType, Vec<SharedSecretKey>), 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<AccountIdentity>,
instruction_data: InstructionData,
program: &ProgramWithDependencies,
) -> Result<(PrivacyPreservingTransaction, Vec<SharedSecretKey>), 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<SharedSecretKey>), 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<AccountIdentity>,
instruction_data: InstructionData,
program: &ProgramWithDependencies,
tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>,
) -> Result<(PrivacyPreservingTransaction, Vec<SharedSecretKey>), 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<HashType, ExecutionFailureKind> {
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<AccountIdentity>,
instruction_data: InstructionData,
program_id: ProgramId,
) -> Result<PublicTransaction, ExecutionFailureKind> {
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<HashType, ExecutionFailureKind> {
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<AccountIdentity>,
instruction_data: InstructionData,
program_id: ProgramId,
tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>,
) -> Result<PublicTransaction, ExecutionFailureKind> {
// 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<u64> {