penumbra_sdk_mock_client/
lib.rs1use anyhow::Error;
2use cnidarium::StateRead;
3use penumbra_sdk_compact_block::{component::StateReadExt as _, CompactBlock, StatePayload};
4use penumbra_sdk_dex::{swap::SwapPlaintext, swap_claim::SwapClaimPlan};
5use penumbra_sdk_keys::{keys::SpendKey, FullViewingKey};
6use penumbra_sdk_sct::{
7 component::{clock::EpochRead, tree::SctRead},
8 Nullifier,
9};
10use penumbra_sdk_shielded_pool::{note, Note, SpendPlan};
11use penumbra_sdk_tct as tct;
12use penumbra_sdk_transaction::{AuthorizationData, Transaction, TransactionPlan, WitnessData};
13use rand_core::OsRng;
14use std::collections::BTreeMap;
15
16pub struct MockClient {
18 latest_height: u64,
19 sk: SpendKey,
20 pub fvk: FullViewingKey,
21 pub notes: BTreeMap<note::StateCommitment, Note>,
23 pub nullifiers: BTreeMap<note::StateCommitment, Nullifier>,
24 pub spent_notes: BTreeMap<note::StateCommitment, ()>,
26 swaps: BTreeMap<tct::StateCommitment, SwapPlaintext>,
27 pub sct: penumbra_sdk_tct::Tree,
28}
29
30impl MockClient {
31 pub fn new(sk: SpendKey) -> MockClient {
32 Self {
33 latest_height: u64::MAX,
34 fvk: sk.full_viewing_key().clone(),
35 sk,
36 notes: Default::default(),
37 spent_notes: Default::default(),
38 nullifiers: Default::default(),
39 sct: Default::default(),
40 swaps: Default::default(),
41 }
42 }
43
44 pub async fn with_sync_to_storage(
45 mut self,
46 storage: impl AsRef<cnidarium::Storage>,
47 ) -> anyhow::Result<Self> {
48 let latest = storage.as_ref().latest_snapshot();
49 self.sync_to_latest(latest).await?;
50
51 Ok(self)
52 }
53
54 pub async fn with_sync_to_inner_storage(
55 mut self,
56 storage: cnidarium::Storage,
57 ) -> anyhow::Result<Self> {
58 let latest = storage.latest_snapshot();
59 self.sync_to_latest(latest).await?;
60
61 Ok(self)
62 }
63
64 pub async fn sync_to_latest<R: StateRead>(&mut self, state: R) -> anyhow::Result<()> {
65 let height = state.get_block_height().await?;
66 self.sync_to(height, state).await?;
67 Ok(())
68 }
69
70 pub async fn sync_to<R: StateRead>(
71 &mut self,
72 target_height: u64,
73 state: R,
74 ) -> anyhow::Result<()> {
75 let start_height = self.latest_height.wrapping_add(1);
76 for height in start_height..=target_height {
77 let compact_block = state
78 .compact_block(height)
79 .await?
80 .ok_or_else(|| anyhow::anyhow!("missing compact block for height {}", height))?;
81 self.scan_block(compact_block.try_into()?)?;
82 let (latest_height, root) = self.latest_height_and_sct_root();
83 anyhow::ensure!(latest_height == height, "latest height should be updated");
84 let expected_root = state
85 .get_anchor_by_height(height)
86 .await?
87 .ok_or_else(|| anyhow::anyhow!("missing sct anchor for height {}", height))?;
88 anyhow::ensure!(
89 root == expected_root,
90 format!(
91 "client sct root should match chain state: {:?} != {:?}",
92 root, expected_root
93 )
94 );
95 }
96 Ok(())
97 }
98
99 pub fn scan_block(&mut self, block: CompactBlock) -> anyhow::Result<()> {
100 use penumbra_sdk_tct::Witness::*;
101
102 if self.latest_height.wrapping_add(1) != block.height {
103 anyhow::bail!(
104 "wrong block height {} for latest height {}",
105 block.height,
106 self.latest_height
107 );
108 }
109
110 for payload in block.state_payloads {
111 match payload {
112 StatePayload::Note { note: payload, .. } => {
113 match payload.trial_decrypt(&self.fvk) {
114 Some(note) => {
115 self.sct.insert(Keep, payload.note_commitment)?;
116 let nullifier = self
117 .nullifier(payload.note_commitment)
118 .expect("newly inserted note should be present in sct");
119 self.notes.insert(payload.note_commitment, note.clone());
120 self.nullifiers.insert(payload.note_commitment, nullifier);
121 }
122 None => {
123 self.sct.insert(Forget, payload.note_commitment)?;
124 }
125 }
126 }
127 StatePayload::Swap { swap: payload, .. } => {
128 match payload.trial_decrypt(&self.fvk) {
129 Some(swap) => {
130 self.sct.insert(Keep, payload.commitment)?;
131 self.swaps.insert(payload.commitment, swap.clone());
135
136 let batch_data =
137 block.swap_outputs.get(&swap.trading_pair).ok_or_else(|| {
138 anyhow::anyhow!("server gave invalid compact block")
139 })?;
140
141 let (output_1, output_2) = swap.output_notes(batch_data);
142 self.notes.insert(output_1.commit(), output_1.clone());
153 self.notes.insert(output_2.commit(), output_2.clone());
155 }
157 None => {
158 self.sct.insert(Forget, payload.commitment)?;
159 }
160 }
161 }
162 StatePayload::RolledUp { commitment, .. } => {
163 if self.notes.contains_key(&commitment) {
164 self.sct.insert(Keep, commitment)?;
166 } else {
167 self.sct.insert(Forget, commitment)?;
169 }
170 }
171 }
172 }
173
174 for nullifier in block.nullifiers {
176 if !self.nullifiers.values().any(move |n| *n == nullifier) {
178 continue;
179 }
180
181 self.spent_notes.insert(
182 *self
183 .nullifiers
184 .iter()
185 .find_map(|(k, v)| if *v == nullifier { Some(k) } else { None })
186 .unwrap(),
187 (),
188 );
189 }
190
191 self.sct.end_block()?;
192 if block.epoch_root.is_some() {
193 self.sct.end_epoch()?;
194 }
195
196 self.latest_height = block.height;
197
198 Ok(())
199 }
200
201 pub fn latest_height_and_sct_root(&self) -> (u64, penumbra_sdk_tct::Root) {
202 (self.latest_height, self.sct.root())
203 }
204
205 pub fn note_by_commitment(&self, commitment: ¬e::StateCommitment) -> Option<Note> {
206 self.notes.get(commitment).cloned()
207 }
208
209 pub fn swap_by_commitment(&self, commitment: ¬e::StateCommitment) -> Option<SwapPlaintext> {
210 self.swaps.get(commitment).cloned()
211 }
212
213 pub fn position(
214 &self,
215 commitment: note::StateCommitment,
216 ) -> Option<penumbra_sdk_tct::Position> {
217 self.sct.witness(commitment).map(|proof| proof.position())
218 }
219
220 pub fn nullifier(&self, commitment: note::StateCommitment) -> Option<Nullifier> {
221 let position = self.position(commitment);
222
223 if position.is_none() {
224 return None;
225 }
226 let nk = self.fvk.nullifier_key();
227
228 Some(Nullifier::derive(&nk, position.unwrap(), &commitment))
229 }
230
231 pub fn witness_commitment(
232 &self,
233 commitment: note::StateCommitment,
234 ) -> Option<penumbra_sdk_tct::Proof> {
235 self.sct.witness(commitment)
236 }
237
238 pub fn witness_plan(&self, plan: &TransactionPlan) -> Result<WitnessData, Error> {
239 let spend_commitment = |spend: &SpendPlan| spend.note.commit();
240 let spends = plan.spend_plans().map(spend_commitment);
241
242 let swap_claim_commitment = |swap: &SwapClaimPlan| swap.swap_plaintext.swap_commitment();
243 let swap_claims = plan.swap_claim_plans().map(swap_claim_commitment);
244
245 let witness = |commitment| {
246 self.sct
247 .witness(commitment)
248 .ok_or_else(|| anyhow::anyhow!("note commitment {commitment:?} unknown to client"))
249 .map(|proof| (commitment, proof))
250 };
251
252 Ok(WitnessData {
253 anchor: self.sct.root(),
254 state_commitment_proofs: spends
256 .chain(swap_claims)
257 .map(witness)
258 .collect::<Result<_, Error>>()?,
259 })
260 }
261
262 pub fn authorize_plan(&self, plan: &TransactionPlan) -> Result<AuthorizationData, Error> {
263 plan.authorize(OsRng, &self.sk)
264 }
265
266 pub async fn witness_auth_build(&self, plan: &TransactionPlan) -> Result<Transaction, Error> {
267 let witness_data = self.witness_plan(plan)?;
268 let auth_data = self.authorize_plan(plan)?;
269 plan.clone()
270 .build_concurrent(&self.fvk, &witness_data, &auth_data)
271 .await
272 }
273
274 pub fn notes_by_asset(
275 &self,
276 asset_id: penumbra_sdk_asset::asset::Id,
277 ) -> impl Iterator<Item = &Note> + '_ {
278 self.notes
279 .values()
280 .filter(move |n| n.asset_id() == asset_id)
281 }
282
283 pub fn spent_note(&self, commitment: ¬e::StateCommitment) -> bool {
284 self.spent_notes.contains_key(commitment)
285 }
286
287 pub fn spendable_notes_by_asset(
288 &self,
289 asset_id: penumbra_sdk_asset::asset::Id,
290 ) -> impl Iterator<Item = &Note> + '_ {
291 self.notes
292 .values()
293 .filter(move |n| n.asset_id() == asset_id && !self.spent_note(&n.commit()))
294 }
295}