diff --git a/Cargo.lock b/Cargo.lock index 3dc33d9306..1d7521d737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5263,6 +5263,8 @@ dependencies = [ "penumbra-sct", "penumbra-shielded-pool", "penumbra-tct", + "penumbra-transaction", + "rand_core 0.6.4", ] [[package]] diff --git a/crates/core/app/src/action_handler/transaction/stateful.rs b/crates/core/app/src/action_handler/transaction/stateful.rs index 074e3e1483..424cfcf1db 100644 --- a/crates/core/app/src/action_handler/transaction/stateful.rs +++ b/crates/core/app/src/action_handler/transaction/stateful.rs @@ -28,6 +28,15 @@ pub async fn fmd_parameters_valid(state: S, transaction: &Transact ) } +#[tracing::instrument( + skip_all, + fields( + current_fmd.precision_bits = current_fmd_parameters.precision_bits, + previous_fmd.precision_bits = previous_fmd_parameters.precision_bits, + previous_fmd.as_of_block_height = previous_fmd_parameters.as_of_block_height, + block_height, + ) +)] pub fn fmd_precision_within_grace_period( tx: &Transaction, previous_fmd_parameters: fmd::Parameters, @@ -42,13 +51,21 @@ pub fn fmd_precision_within_grace_period( { // Clue must be using the current `fmd::Parameters`, or be within // `FMD_GRACE_PERIOD_BLOCKS` of the previous `fmd::Parameters`. - if clue.precision_bits() == current_fmd_parameters.precision_bits - || (clue.precision_bits() == previous_fmd_parameters.precision_bits - && block_height - < previous_fmd_parameters.as_of_block_height + FMD_GRACE_PERIOD_BLOCKS) - { + let clue_precision = clue.precision_bits(); + let using_current_precision = clue_precision == current_fmd_parameters.precision_bits; + let using_previous_precision = clue_precision == previous_fmd_parameters.precision_bits; + let within_grace_period = + block_height < previous_fmd_parameters.as_of_block_height + FMD_GRACE_PERIOD_BLOCKS; + if using_current_precision || (using_previous_precision && within_grace_period) { continue; } else { + tracing::error!( + %clue_precision, + %using_current_precision, + %using_previous_precision, + %within_grace_period, + "invalid clue precision" + ); anyhow::bail!("consensus rule violated: invalid clue precision"); } } diff --git a/crates/core/app/tests/mock_consensus.rs b/crates/core/app/tests/mock_consensus.rs index 3b7c682c68..c68b8e3397 100644 --- a/crates/core/app/tests/mock_consensus.rs +++ b/crates/core/app/tests/mock_consensus.rs @@ -10,13 +10,16 @@ use { cnidarium::TempStorage, penumbra_keys::test_keys, penumbra_mock_client::MockClient, + penumbra_mock_consensus::TestNode, penumbra_proto::DomainType, - penumbra_sct::component::clock::EpochRead, - penumbra_shielded_pool::SpendPlan, - penumbra_transaction::TransactionPlan, - std::ops::Deref, + penumbra_sct::component::{clock::EpochRead, tree::SctRead as _}, + penumbra_shielded_pool::{OutputPlan, SpendPlan}, + penumbra_transaction::{ + memo::MemoPlaintext, plan::MemoPlan, TransactionParameters, TransactionPlan, + }, + rand_core::OsRng, tap::Tap, - tracing::{error_span, Instrument}, + tracing::{error_span, info, Instrument}, }; /// Exercises that a test node can be instantiated using the consensus service. @@ -75,59 +78,88 @@ async fn mock_consensus_can_send_a_sequence_of_empty_blocks() -> anyhow::Result< } #[tokio::test] -async fn mock_consensus_can_send_a_spend_action() -> anyhow::Result<()> { +async fn mock_consensus_can_spend_notes_and_detect_outputs() -> anyhow::Result<()> { // Install a test logger, acquire some temporary storage, and start the test node. let guard = common::set_tracing_subscriber(); let storage = TempStorage::new().await?; let mut test_node = common::start_test_node(&storage).await?; - let mut rng = ::seed_from_u64(0xBEEF); - // Sync the mock client, using the test account's full viewing key, to the latest snapshot. - let (viewing_key, spend_key) = (&test_keys::FULL_VIEWING_KEY, &test_keys::SPEND_KEY); - let client = MockClient::new(viewing_key.deref().clone()) + // Sync the mock client, using the test wallet's spend key, to the latest snapshot. + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()) .with_sync_to_storage(&storage) - .await?; + .await? + .tap(|c| info!(client.notes = %c.notes.len(), "mock client synced to test storage")); - // Take one of the test account's notes... - let (commitment, note) = client + // Take one of the test wallet's notes, and send it to a different account. + let input_note = client .notes - .iter() + .values() + .cloned() .next() - .ok_or_else(|| anyhow!("mock client had no note"))? - .tap(|(commitment, note)| { - tracing::info!(?commitment, ?note, "mock client note commitment") - }); - - // Build a transaction spending this note. - let tx = { - let position = client - .sct - .witness(*commitment) - .ok_or_else(|| anyhow!("commitment is not witnessed"))? - .position(); - let spend = SpendPlan::new(&mut rng, note.clone(), position); - let plan = TransactionPlan { - actions: vec![spend.into()], + .ok_or_else(|| anyhow!("mock client had no note"))?; + + // Write down a transaction plan with exactly one spend and one output. + let mut plan = TransactionPlan { + actions: vec![ + // First, spend the selected input note. + SpendPlan::new( + &mut OsRng, + input_note.clone(), + // Spends require _positioned_ notes, in order to compute their nullifiers. + client + .position(input_note.commit()) + .ok_or_else(|| anyhow!("input note commitment was unknown to mock client"))?, + ) + .into(), + // Next, create a new output of the exact same amount. + OutputPlan::new(&mut OsRng, input_note.value(), *test_keys::ADDRESS_1).into(), + ], + // Now fill out the remaining parts of the transaction needed for verification: + memo: Some(MemoPlan::new( + &mut OsRng, + MemoPlaintext::blank_memo(*test_keys::ADDRESS_0), + )?), + detection_data: None, // We'll set this automatically below + transaction_parameters: TransactionParameters { + chain_id: TestNode::<()>::CHAIN_ID.to_string(), ..Default::default() - }; - let witness = plan.witness_data(&client.sct)?; - let auth = plan.authorize(rand_core::OsRng, spend_key)?; - plan.build_concurrent(viewing_key, &witness, &auth).await? + }, }; + plan.populate_detection_data(OsRng, 0); + + let tx = client.witness_auth_build(&plan).await?; - // Execute the transaction, and sync another mock client up to the latest snapshot. + // Execute the transaction, applying it to the chain state. + let pre_tx_snapshot = storage.latest_snapshot(); test_node .block() - .with_data(vec![tx.encode_to_vec()]) // TODO(kate): add a `with_tx` extension method + .with_data(vec![tx.encode_to_vec()]) .execute() .await?; + let post_tx_snapshot = storage.latest_snapshot(); - // Sync to the latest storage snapshot once more. - let client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()) - .with_sync_to_storage(&storage) - .await?; + // Check that the nullifiers were spent as a result of the transaction: + for nf in tx.spent_nullifiers() { + assert!(pre_tx_snapshot.spend_info(nf).await?.is_none()); + assert!(post_tx_snapshot.spend_info(nf).await?.is_some()); + } + + // Sync the client up to the current block + client.sync_to_latest(post_tx_snapshot).await?; - client.notes.get(&commitment).unwrap(); + // Check that the client was able to detect the new note: + + // Grab the output note we're expecting to see... + let output_nc = tx + .outputs() + .next() + .expect("tx has one output") + .body + .note_payload + .note_commitment + .clone(); + // ... and check that it's now in the client's note set. + assert!(client.notes.contains_key(&output_nc)); // Free our temporary storage. drop(storage); diff --git a/crates/core/app/tests/spend.rs b/crates/core/app/tests/spend.rs index 0b368a94e8..be23991889 100644 --- a/crates/core/app/tests/spend.rs +++ b/crates/core/app/tests/spend.rs @@ -35,7 +35,7 @@ async fn spend_happy_path() -> anyhow::Result<()> { let height = 1; // Precondition: This test uses the default genesis which has existing notes for the test keys. - let mut client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()); + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()); let sk = test_keys::SPEND_KEY.clone(); client.sync_to(0, state.deref()).await?; let note = client.notes.values().next().unwrap().clone(); @@ -112,7 +112,7 @@ async fn invalid_dummy_spend() { let height = 1; // Precondition: This test uses the default genesis which has existing notes for the test keys. - let mut client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()); + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()); let sk = test_keys::SPEND_KEY.clone(); client.sync_to(0, state.deref()).await.unwrap(); let note = client.notes.values().next().unwrap().clone(); @@ -212,7 +212,7 @@ async fn spend_duplicate_nullifier_previous_transaction() { let height = 1; // Precondition: This test uses the default genesis which has existing notes for the test keys. - let mut client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()); + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()); let sk = test_keys::SPEND_KEY.clone(); client .sync_to(0, state.deref()) @@ -303,7 +303,7 @@ async fn spend_duplicate_nullifier_same_transaction() { let height = 1; // Precondition: This test uses the default genesis which has existing notes for the test keys. - let mut client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()); + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()); let sk = test_keys::SPEND_KEY.clone(); client .sync_to(0, state.deref()) diff --git a/crates/core/app/tests/swap_and_swap_claim.rs b/crates/core/app/tests/swap_and_swap_claim.rs index 8cdd1a113c..8698f69513 100644 --- a/crates/core/app/tests/swap_and_swap_claim.rs +++ b/crates/core/app/tests/swap_and_swap_claim.rs @@ -96,14 +96,14 @@ async fn swap_and_swap_claim() -> anyhow::Result<()> { // means we have to synchronize a client's view of the test chain's SCT // state. let epoch_duration = state.get_epoch_duration_parameter().await?; - let mut client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()); + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()); // TODO: generalize StateRead/StateWrite impls from impl for &S to impl for Deref client.sync_to(1, state.deref()).await?; let output_data = state.output_data(height, trading_pair).await?.unwrap(); let commitment = swap.body.payload.commitment; - let swap_auth_path = client.witness(commitment).unwrap(); + let swap_auth_path = client.witness_commitment(commitment).unwrap(); let detected_plaintext = client.swap_by_commitment(&commitment).unwrap(); assert_eq!(plaintext, detected_plaintext); @@ -207,7 +207,7 @@ async fn swap_claim_duplicate_nullifier_previous_transaction() { // 6. Create a SwapClaim action let epoch_duration = state.get_epoch_duration_parameter().await.unwrap(); - let mut client = MockClient::new(test_keys::FULL_VIEWING_KEY.clone()); + let mut client = MockClient::new(test_keys::SPEND_KEY.clone()); client.sync_to(1, state.deref()).await.unwrap(); let output_data = state @@ -217,7 +217,7 @@ async fn swap_claim_duplicate_nullifier_previous_transaction() { .unwrap(); let commitment = swap.body.payload.commitment; - let swap_auth_path = client.witness(commitment).unwrap(); + let swap_auth_path = client.witness_commitment(commitment).unwrap(); let detected_plaintext = client.swap_by_commitment(&commitment).unwrap(); assert_eq!(plaintext, detected_plaintext); diff --git a/crates/core/transaction/src/memo.rs b/crates/core/transaction/src/memo.rs index f67ca82ff1..1f76604784 100644 --- a/crates/core/transaction/src/memo.rs +++ b/crates/core/transaction/src/memo.rs @@ -103,9 +103,9 @@ impl MemoPlaintext { self.into() } - pub fn blank_memo(address: Address) -> MemoPlaintext { + pub fn blank_memo(return_address: Address) -> MemoPlaintext { MemoPlaintext { - return_address: address, + return_address, text: String::new(), } } diff --git a/crates/test/mock-client/Cargo.toml b/crates/test/mock-client/Cargo.toml index a8ecc75839..6d8baae17c 100644 --- a/crates/test/mock-client/Cargo.toml +++ b/crates/test/mock-client/Cargo.toml @@ -18,3 +18,6 @@ penumbra-shielded-pool = {workspace = true, features = [ "component", ], default-features = true} penumbra-tct = {workspace = true, default-features = true} +penumbra-transaction = {workspace = true, default-features = true} +rand_core = {workspace = true} + diff --git a/crates/test/mock-client/src/lib.rs b/crates/test/mock-client/src/lib.rs index b938346b49..a58b017ef7 100644 --- a/crates/test/mock-client/src/lib.rs +++ b/crates/test/mock-client/src/lib.rs @@ -1,15 +1,19 @@ +use anyhow::Error; use cnidarium::StateRead; use penumbra_compact_block::{component::StateReadExt as _, CompactBlock, StatePayload}; use penumbra_dex::swap::SwapPlaintext; -use penumbra_keys::FullViewingKey; +use penumbra_keys::{keys::SpendKey, FullViewingKey}; use penumbra_sct::component::{clock::EpochRead, tree::SctRead}; use penumbra_shielded_pool::{note, Note}; use penumbra_tct as tct; +use penumbra_transaction::{AuthorizationData, Transaction, TransactionPlan, WitnessData}; +use rand_core::OsRng; use std::collections::BTreeMap; /// A bare-bones mock client for use exercising the state machine. pub struct MockClient { latest_height: u64, + sk: SpendKey, pub fvk: FullViewingKey, pub notes: BTreeMap, swaps: BTreeMap, @@ -17,10 +21,11 @@ pub struct MockClient { } impl MockClient { - pub fn new(fvk: FullViewingKey) -> MockClient { + pub fn new(sk: SpendKey) -> MockClient { Self { latest_height: u64::MAX, - fvk, + fvk: sk.full_viewing_key().clone(), + sk, notes: Default::default(), sct: Default::default(), swaps: Default::default(), @@ -32,19 +37,24 @@ impl MockClient { storage: impl AsRef, ) -> anyhow::Result { let latest = storage.as_ref().latest_snapshot(); - let height = latest.get_block_height().await?; - let state = cnidarium::StateDelta::new(latest); - self.sync_to(height, state).await?; + self.sync_to_latest(latest).await?; Ok(self) } + pub async fn sync_to_latest(&mut self, state: R) -> anyhow::Result<()> { + let height = state.get_block_height().await?; + self.sync_to(height, state).await?; + Ok(()) + } + pub async fn sync_to( &mut self, target_height: u64, state: R, ) -> anyhow::Result<()> { - for height in 0..=target_height { + let start_height = self.latest_height.wrapping_add(1); + for height in start_height..=target_height { let compact_block = state .compact_block(height) .await? @@ -149,7 +159,45 @@ impl MockClient { self.swaps.get(commitment).cloned() } - pub fn witness(&self, commitment: note::StateCommitment) -> Option { + pub fn position(&self, commitment: note::StateCommitment) -> Option { + self.sct.witness(commitment).map(|proof| proof.position()) + } + + pub fn witness_commitment( + &self, + commitment: note::StateCommitment, + ) -> Option { self.sct.witness(commitment) } + + pub fn witness_plan(&self, plan: &TransactionPlan) -> Result { + Ok(WitnessData { + anchor: self.sct.root(), + // TODO: this will only witness spends, not other proofs like swaps + state_commitment_proofs: plan + .spend_plans() + .map(|spend| { + let nc = spend.note.commit(); + Ok(( + nc, + self.sct.witness(nc).ok_or_else(|| { + anyhow::anyhow!("note commitment {:?} unknown to client", nc) + })?, + )) + }) + .collect::>()?, + }) + } + + pub fn authorize_plan(&self, plan: &TransactionPlan) -> Result { + plan.authorize(OsRng, &self.sk) + } + + pub async fn witness_auth_build(&self, plan: &TransactionPlan) -> Result { + let witness_data = self.witness_plan(plan)?; + let auth_data = self.authorize_plan(plan)?; + plan.clone() + .build_concurrent(&self.fvk, &witness_data, &auth_data) + .await + } } diff --git a/crates/test/mock-consensus/src/builder/init_chain.rs b/crates/test/mock-consensus/src/builder/init_chain.rs index 06079ac07d..b60a7793fd 100644 --- a/crates/test/mock-consensus/src/builder/init_chain.rs +++ b/crates/test/mock-consensus/src/builder/init_chain.rs @@ -67,10 +67,11 @@ impl Builder { fn init_chain_request(app_state_bytes: Bytes) -> Result { use tendermint::v0_37::abci::request::InitChain; + let chain_id = TestNode::<()>::CHAIN_ID.to_string(); let consensus_params = Self::consensus_params(); Ok(ConsensusRequest::InitChain(InitChain { time: tendermint::Time::now(), - chain_id: "test".to_string(), // XXX const here? + chain_id, consensus_params, validators: vec![], app_state_bytes, diff --git a/crates/test/mock-consensus/src/lib.rs b/crates/test/mock-consensus/src/lib.rs index 4fd88998cb..1bc9750200 100644 --- a/crates/test/mock-consensus/src/lib.rs +++ b/crates/test/mock-consensus/src/lib.rs @@ -25,6 +25,8 @@ pub struct TestNode { } impl TestNode { + pub const CHAIN_ID: &'static str = "penumbra-test-chain"; + /// Returns the last app_hash value, as a slice of bytes. pub fn last_app_hash(&self) -> &[u8] { &self.last_app_hash