From 9b83f2c79721707d2d39c9f167632e12ae8282c5 Mon Sep 17 00:00:00 2001 From: Conor Schaefer Date: Wed, 20 Sep 2023 13:36:04 -0700 Subject: [PATCH] feat(wasm)!: refactor transaction planner and view Collects several improvements to the WASM interface for building transactions via the planner: * added wasm planner which allows more flexible creation of TransactionPlan on TS side * added logic of storing and reading advice when scanning blocks * added function for generating ephemeral address * planner reads data from indexedDB directly from rust Co-authored-by: Valentine --- Cargo.lock | 3 + crates/wasm/Cargo.toml | 4 + crates/wasm/src/error.rs | 69 ++++ crates/wasm/src/keys.rs | 106 ++++++ crates/wasm/src/lib.rs | 124 +------ crates/wasm/src/note_record.rs | 3 +- crates/wasm/src/planner.rs | 395 +++++++++++---------- crates/wasm/src/storage.rs | 172 +++++++++ crates/wasm/src/tx.rs | 303 +++++++++++----- crates/wasm/src/utils.rs | 23 +- crates/wasm/src/view_server.rs | 598 ++++++++------------------------ crates/wasm/src/wasm_planner.rs | 188 ++++++++++ 12 files changed, 1135 insertions(+), 853 deletions(-) create mode 100644 crates/wasm/src/error.rs create mode 100644 crates/wasm/src/keys.rs create mode 100644 crates/wasm/src/storage.rs create mode 100644 crates/wasm/src/wasm_planner.rs diff --git a/Cargo.lock b/Cargo.lock index ce29ebb07c..32fb0b7e8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5733,8 +5733,10 @@ name = "penumbra-wasm" version = "0.60.0" dependencies = [ "anyhow", + "ark-ff", "base64 0.21.4", "console_error_panic_hook", + "decaf377 0.5.0", "hex", "indexed_db_futures", "penumbra-asset", @@ -5753,6 +5755,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde-wasm-bindgen", + "thiserror", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index f6daa4e1d0..46875de590 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -29,15 +29,19 @@ penumbra-tct = { path = "../crypto/tct" } penumbra-transaction = { path = "../core/transaction", default-features = false } anyhow = "1.0.75" +ark-ff = { version = "0.4.2", features = ["std"] } base64 = "0.21.2" console_error_panic_hook = { version = "0.1.7", optional = true } +decaf377 = { version = "0.5", features = ["r1cs"] } hex = "0.4.3" indexed_db_futures = "0.3.0" rand_core = { version = "0.6.4", features = ["getrandom"] } serde = { version = "1.0.186", features = ["derive"] } serde-wasm-bindgen = "0.5.0" +thiserror = "1.0" wasm-bindgen = "0.2.87" wasm-bindgen-futures = "0.4.37" +wasm-bindgen-test = "0.3.0" web-sys = { version = "0.3.64", features = ["console"] } [dev-dependencies] diff --git a/crates/wasm/src/error.rs b/crates/wasm/src/error.rs new file mode 100644 index 0000000000..edd1df65f8 --- /dev/null +++ b/crates/wasm/src/error.rs @@ -0,0 +1,69 @@ +use base64::DecodeError; +use hex::FromHexError; +use penumbra_tct::error::{InsertBlockError, InsertEpochError, InsertError}; +use serde_wasm_bindgen::Error; +use std::convert::Infallible; +use thiserror::Error; +use wasm_bindgen::{JsError, JsValue}; +use web_sys::DomException; + +pub type WasmResult = Result; + +#[derive(Error, Debug)] +pub enum WasmError { + #[error("{0}")] + Anyhow(#[from] anyhow::Error), + + #[error("{0}")] + DecodeError(#[from] DecodeError), + + #[error("{0}")] + Dom(#[from] DomError), + + #[error("{0}")] + FromHexError(#[from] FromHexError), + + #[error("{0}")] + Infallible(#[from] Infallible), + + #[error("{0}")] + InsertBlockError(#[from] InsertBlockError), + + #[error("{0}")] + InsertEpochError(#[from] InsertEpochError), + + #[error("{0}")] + InsertError(#[from] InsertError), + + #[error("{0}")] + Wasm(#[from] serde_wasm_bindgen::Error), +} + +impl From for serde_wasm_bindgen::Error { + fn from(wasm_err: WasmError) -> Self { + Error::new(wasm_err.to_string()) + } +} + +impl From for JsValue { + fn from(error: WasmError) -> Self { + JsError::from(error).into() + } +} + +#[derive(Debug)] +pub struct DomError(DomException); + +impl std::fmt::Display for DomError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "DOM Exception: {:?}", self.0) + } +} + +impl std::error::Error for DomError {} + +impl From for WasmError { + fn from(dom_exception: DomException) -> Self { + WasmError::Dom(DomError(dom_exception)) + } +} diff --git a/crates/wasm/src/keys.rs b/crates/wasm/src/keys.rs new file mode 100644 index 0000000000..0b128a7673 --- /dev/null +++ b/crates/wasm/src/keys.rs @@ -0,0 +1,106 @@ +use crate::error::WasmResult; +use penumbra_keys::keys::{SeedPhrase, SpendKey}; +use penumbra_keys::{Address, FullViewingKey}; +use penumbra_proto::{core::crypto::v1alpha1 as pb, serializers::bech32str, DomainType}; +use rand_core::OsRng; +use std::str::FromStr; +use wasm_bindgen::prelude::*; + +/// generate a spend key from a seed phrase +/// Arguments: +/// seed_phrase: `string` +/// Returns: `bech32 string` +#[wasm_bindgen] +pub fn generate_spend_key(seed_phrase: &str) -> WasmResult { + let seed = SeedPhrase::from_str(seed_phrase)?; + let spend_key = SpendKey::from_seed_phrase_bip39(seed, 0); + + let proto = spend_key.to_proto(); + + let spend_key_str = bech32str::encode( + &proto.inner, + bech32str::spend_key::BECH32_PREFIX, + bech32str::Bech32m, + ); + + Ok(JsValue::from_str(&spend_key_str)) +} + +/// get full viewing key from spend key +/// Arguments: +/// spend_key_str: `bech32 string` +/// Returns: `bech32 string` +#[wasm_bindgen] +pub fn get_full_viewing_key(spend_key: &str) -> WasmResult { + let spend_key = SpendKey::from_str(spend_key)?; + + let fvk: &FullViewingKey = spend_key.full_viewing_key(); + + let proto = fvk.to_proto(); + + let fvk_bech32 = bech32str::encode( + &proto.inner, + bech32str::full_viewing_key::BECH32_PREFIX, + bech32str::Bech32m, + ); + Ok(JsValue::from_str(&fvk_bech32)) +} + +/// get address by index using FVK +/// Arguments: +/// full_viewing_key: `bech32 string` +/// index: `u32` +/// Returns: `pb::Address` +#[wasm_bindgen] +pub fn get_address_by_index(full_viewing_key: &str, index: u32) -> WasmResult { + let fvk = FullViewingKey::from_str(full_viewing_key)?; + let (address, _dtk) = fvk.incoming().payment_address(index.into()); + let proto = address.to_proto(); + let result = serde_wasm_bindgen::to_value(&proto)?; + Ok(result) +} + +/// get ephemeral (randomizer) address using FVK +/// The derivation tree is like "spend key / address index / ephemeral address" so we must also pass index as an argument +/// Arguments: +/// full_viewing_key: `bech32 string` +/// index: `u32` +/// Returns: `pb::Address` +#[wasm_bindgen] +pub fn get_ephemeral_address(full_viewing_key: &str, index: u32) -> WasmResult { + let fvk = FullViewingKey::from_str(full_viewing_key)?; + let (address, _dtk) = fvk.ephemeral_address(OsRng, index.into()); + let proto = address.to_proto(); + let result = serde_wasm_bindgen::to_value(&proto)?; + Ok(result) +} + +/// Check if the address is FVK controlled +/// Arguments: +/// full_viewing_key: `bech32 String` +/// address: `bech32 String` +/// Returns: `Option` +#[wasm_bindgen] +pub fn is_controlled_address(full_viewing_key: &str, address: &str) -> WasmResult { + let fvk = FullViewingKey::from_str(full_viewing_key)?; + let index: Option = fvk + .address_index(&Address::from_str(address)?) + .map(Into::into); + let result = serde_wasm_bindgen::to_value(&index)?; + Ok(result) +} + +/// Get canonical short form address by index +/// This feature is probably redundant and will be removed from wasm in the future +/// Arguments: +/// full_viewing_key: `bech32 string` +/// index: `u32` +/// Returns: `String` +#[wasm_bindgen] +pub fn get_short_address_by_index(full_viewing_key: &str, index: u32) -> WasmResult { + let fvk = FullViewingKey::from_str(full_viewing_key)?; + + let (address, _dtk) = fvk.incoming().payment_address(index.into()); + let short_address = address.display_short_form(); + Ok(JsValue::from_str(&short_address)) +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 309d61aa86..8fa1afbd9b 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -1,131 +1,15 @@ -#![deny(clippy::unwrap_used)] #![allow(dead_code)] extern crate core; +mod error; +mod keys; mod note_record; mod planner; +mod storage; mod swap_record; mod tx; mod utils; mod view_server; -use penumbra_proto::{core::crypto::v1alpha1 as pb, serializers::bech32str, DomainType}; +mod wasm_planner; -use penumbra_keys::{Address, FullViewingKey}; -use std::convert::TryFrom; -use std::str::FromStr; - -use penumbra_keys::keys::{SeedPhrase, SpendKey}; -use wasm_bindgen::prelude::*; - -use penumbra_transaction::Transaction; - -pub use tx::send_plan; pub use view_server::ViewServer; - -#[wasm_bindgen] -pub fn generate_spend_key(seed_phrase: &str) -> JsValue { - utils::set_panic_hook(); - let seed = - SeedPhrase::from_str(seed_phrase).expect("the provided string is a valid seed phrase"); - let spend_key = SpendKey::from_seed_phrase_bip39(seed, 0); - - let proto = spend_key.to_proto(); - let spend_key_str = &bech32str::encode( - &proto.inner, - bech32str::spend_key::BECH32_PREFIX, - bech32str::Bech32m, - ); - - serde_wasm_bindgen::to_value(&spend_key_str).expect("able to serialize spend key") -} - -#[wasm_bindgen] -pub fn get_full_viewing_key(spend_key_str: &str) -> JsValue { - utils::set_panic_hook(); - let spend_key = - SpendKey::from_str(spend_key_str).expect("the provided string is a valid spend key"); - - let fvk: &FullViewingKey = spend_key.full_viewing_key(); - - let proto = pb::FullViewingKey::from(fvk.to_proto()); - - let fvk_str = &bech32str::encode( - &proto.inner, - bech32str::full_viewing_key::BECH32_PREFIX, - bech32str::Bech32m, - ); - serde_wasm_bindgen::to_value(&fvk_str).expect("able to serialize full viewing key") -} - -#[wasm_bindgen] -pub fn get_address_by_index(full_viewing_key: &str, index: u32) -> JsValue { - utils::set_panic_hook(); - let fvk = FullViewingKey::from_str(full_viewing_key.as_ref()) - .expect("the provided string is a valid FullViewingKey"); - - let (address, _dtk) = fvk.incoming().payment_address(index.into()); - - let proto = address.to_proto(); - let address_str = &bech32str::encode( - &proto.inner, - bech32str::address::BECH32_PREFIX, - bech32str::Bech32m, - ); - - serde_wasm_bindgen::to_value(&address_str).expect("able to serialize address") -} - -#[wasm_bindgen] -pub fn base64_to_bech32(prefix: &str, base64_str: &str) -> JsValue { - utils::set_panic_hook(); - - let bech32 = &bech32str::encode( - &base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_str) - .expect("the provided string is a valid base64 string"), - prefix, - bech32str::Bech32m, - ); - serde_wasm_bindgen::to_value(bech32).expect("able to serialize bech32 string") -} -#[wasm_bindgen] -pub fn is_controlled_address(full_viewing_key: &str, address: &str) -> JsValue { - utils::set_panic_hook(); - let fvk = FullViewingKey::from_str(full_viewing_key.as_ref()) - .expect("the provided string is a valid FullViewingKey"); - - let index = fvk.address_index(&Address::from_str(address.as_ref()).expect("valid address")); - - serde_wasm_bindgen::to_value(&index).expect("able to serialize address index") -} - -#[wasm_bindgen] -pub fn get_short_address_by_index(full_viewing_key: &str, index: u32) -> JsValue { - utils::set_panic_hook(); - let fvk = FullViewingKey::from_str(full_viewing_key.as_ref()) - .expect("The provided string is not a valid FullViewingKey"); - - let (address, _dtk) = fvk.incoming().payment_address(index.into()); - let short_address = address.display_short_form(); - serde_wasm_bindgen::to_value(&short_address).expect("able to serialize address") -} - -#[wasm_bindgen] -pub fn decode_transaction(tx_bytes: &str) -> JsValue { - utils::set_panic_hook(); - let tx_vec: Vec = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, tx_bytes) - .expect("the provided tx string is a valid base64 string"); - let transaction: Transaction = - Transaction::try_from(tx_vec).expect("the provided tx string is a valid transaction"); - serde_wasm_bindgen::to_value(&transaction).expect("able to serialize transaction") -} - -#[wasm_bindgen] -pub fn decode_nct_root(tx_bytes: &str) -> JsValue { - utils::set_panic_hook(); - let tx_vec: Vec = - hex::decode(tx_bytes).expect("the provided tx string is a valid hex string"); - let root = penumbra_tct::Root::decode(tx_vec.as_slice()) - .expect("the provided tx string is a valid nct root"); - serde_wasm_bindgen::to_value(&root).expect("able to serialize nct root") -} diff --git a/crates/wasm/src/note_record.rs b/crates/wasm/src/note_record.rs index 4ae1485689..4e06a446e1 100644 --- a/crates/wasm/src/note_record.rs +++ b/crates/wasm/src/note_record.rs @@ -4,9 +4,8 @@ use penumbra_proto::{view::v1alpha1 as pb, DomainType, TypeUrl}; use penumbra_sct::Nullifier; use penumbra_shielded_pool::{note, Note}; use penumbra_tct as tct; -use std::convert::{TryFrom, TryInto}; - use serde::{Deserialize, Serialize}; +use std::convert::{TryFrom, TryInto}; /// Corresponds to the SpendableNoteRecord proto #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/crates/wasm/src/planner.rs b/crates/wasm/src/planner.rs index 861b05b5c8..3d978a46d5 100644 --- a/crates/wasm/src/planner.rs +++ b/crates/wasm/src/planner.rs @@ -4,27 +4,39 @@ use std::{ mem, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use rand_core::{CryptoRng, RngCore}; -use crate::note_record::SpendableNoteRecord; use penumbra_asset::{asset::DenomMetadata, Balance, Value}; use penumbra_chain::params::{ChainParameters, FmdParameters}; -use penumbra_dex::{swap::SwapPlaintext, swap::SwapPlan, swap_claim::SwapClaimPlan, TradingPair}; +use penumbra_dex::{ + lp::action::{PositionClose, PositionOpen}, + lp::plan::PositionWithdrawPlan, + lp::position::{self, Position}, + lp::Reserves, + swap::SwapPlaintext, + swap::SwapPlan, + swap_claim::SwapClaimPlan, + TradingPair, +}; use penumbra_fee::Fee; -use penumbra_keys::{keys::AddressIndex, Address, FullViewingKey}; +use penumbra_keys::Address; use penumbra_num::Amount; +use penumbra_proto::view::v1alpha1::{NotesForVotingRequest, NotesRequest}; use penumbra_shielded_pool::{Note, OutputPlan, SpendPlan}; +use penumbra_stake::{rate::RateData, validator}; use penumbra_stake::{IdentityKey, UndelegateClaimPlan}; use penumbra_tct as tct; use penumbra_transaction::{ - action::{Proposal, ProposalSubmit, ProposalWithdraw, ValidatorVote, Vote}, + action::{ + Proposal, ProposalDepositClaim, ProposalSubmit, ProposalWithdraw, ValidatorVote, Vote, + }, memo::MemoPlaintext, plan::{ActionPlan, DelegatorVotePlan, MemoPlan, TransactionPlan}, + proposal, }; -// use penumbra_view::{SpendableNoteRecord, ViewClient}; -use rand_core::{CryptoRng, RngCore}; -// use tracing::instrument; +use crate::note_record::SpendableNoteRecord; /// A planner for a [`TransactionPlan`] that can fill in the required spends and change outputs upon /// finalization to make a transaction balance. @@ -40,7 +52,7 @@ pub struct Planner { struct VoteIntent { start_block_height: u64, start_position: tct::Position, - // rate_data: BTreeMap, + rate_data: BTreeMap, vote: Vote, } @@ -69,42 +81,38 @@ impl Planner { &self.balance } - // /// Get all the note requests necessary to fulfill the current [`Balance`]. - // pub fn notes_requests( - // &self, - // fvk: &FullViewingKey, - // source: AddressIndex, - // ) -> (Vec, Vec) { - // ( - // self.balance - // .required() - // .map(|Value { asset_id, amount }| NotesRequest { - // account_id: Some(fvk.hash().into()), - // asset_id: Some(asset_id.into()), - // address_index: Some(source.into()), - // amount_to_spend: amount.into(), - // include_spent: false, - // ..Default::default() - // }) - // .collect(), - // self.vote_intents - // .iter() - // .map( - // |( - // _proposal, // The request only cares about the start block height - // VoteIntent { - // start_block_height, .. - // }, - // )| NotesForVotingRequest { - // account_id: Some(fvk.hash().into()), - // votable_at_height: *start_block_height, - // address_index: Some(source.into()), - // ..Default::default() - // }, - // ) - // .collect(), - // ) - // } + /// Get all the note requests necessary to fulfill the current [`Balance`]. + pub fn notes_requests(&self) -> (Vec, Vec) { + ( + self.balance + .required() + .map(|Value { asset_id, amount }| NotesRequest { + account_group_id: None, + asset_id: Some(asset_id.into()), + address_index: None, + amount_to_spend: Some(amount.into()), + include_spent: false, + ..Default::default() + }) + .collect(), + self.vote_intents + .iter() + .map( + |( + _proposal, // The request only cares about the start block height + VoteIntent { + start_block_height, .. + }, + )| NotesForVotingRequest { + account_group_id: None, + votable_at_height: *start_block_height, + address_index: None, + ..Default::default() + }, + ) + .collect(), + ) + } /// Set the expiry height for the transaction plan. pub fn expiry_height(&mut self, expiry_height: u64) -> &mut Self { @@ -115,7 +123,7 @@ impl Planner { /// Set a memo for this transaction plan. /// /// Errors if the memo is too long. - pub fn memo(&mut self, memo: MemoPlaintext) -> anyhow::Result<&mut Self> { + pub fn memo(&mut self, memo: MemoPlaintext) -> Result<&mut Self> { self.plan.memo_plan = Some(MemoPlan::new(&mut self.rng, memo)?); Ok(self) } @@ -139,6 +147,33 @@ impl Planner { self } + /// Open a liquidity position in the order book. + pub fn position_open(&mut self, position: Position) -> &mut Self { + self.action(ActionPlan::PositionOpen(PositionOpen { position })); + self + } + + /// Close a liquidity position in the order book. + pub fn position_close(&mut self, position_id: position::Id) -> &mut Self { + self.action(ActionPlan::PositionClose(PositionClose { position_id })); + self + } + + /// Withdraw a liquidity position in the order book. + pub fn position_withdraw( + &mut self, + position_id: position::Id, + reserves: Reserves, + pair: TradingPair, + ) -> &mut Self { + self.action(ActionPlan::PositionWithdraw(PositionWithdrawPlan::new( + reserves, + position_id, + pair, + ))); + self + } + /// Perform a swap claim based on an input swap NFT with a pre-paid fee. pub fn swap_claim(&mut self, plan: SwapClaimPlan) -> &mut Self { // Nothing needs to be spent, since the fee is pre-paid and the @@ -174,7 +209,7 @@ impl Planner { // If there is no input, then there is no swap. if delta_1 == Amount::zero() && delta_2 == Amount::zero() { - anyhow::bail!("No input value for swap"); + return Err(anyhow!("No input value for swap")); } // Create the `SwapPlaintext` representing the swap to be performed: @@ -203,30 +238,23 @@ impl Planner { self } - // /// Add a delegation to this transaction. - // /// - // /// If you don't specify spends or outputs as well, they will be filled in automatically. - // pub fn delegate(&mut self, unbonded_amount: u64, rate_data: RateData) -> &mut Self { - // let delegation = rate_data.build_delegate(unbonded_amount).into(); - // self.action(delegation); - // self - // } - - // /// Add an undelegation to this transaction. - // /// - // /// TODO: can we put the chain parameters into the planner at the start, so we can compute end_epoch_index? - // pub fn undelegate( - // &mut self, - // delegation_amount: Amount, - // rate_data: RateData, - // end_epoch_index: u64, - // ) -> &mut Self { - // let undelegation = rate_data - // .build_undelegate(delegation_amount, end_epoch_index) - // .into(); - // self.action(undelegation); - // self - // } + /// Add a delegation to this transaction. + /// + /// If you don't specify spends or outputs as well, they will be filled in automatically. + pub fn delegate(&mut self, unbonded_amount: u128, rate_data: RateData) -> &mut Self { + let delegation = rate_data.build_delegate(unbonded_amount).into(); + self.action(delegation); + self + } + + /// Add an undelegation to this transaction. + /// + /// TODO: can we put the chain parameters into the planner at the start, so we can compute end_epoch_index? + pub fn undelegate(&mut self, delegation_amount: Amount, rate_data: RateData) -> &mut Self { + let undelegation = rate_data.build_undelegate(delegation_amount).into(); + self.action(undelegation); + self + } /// Add an undelegate claim to this transaction. pub fn undelegate_claim(&mut self, claim_plan: UndelegateClaimPlan) -> &mut Self { @@ -234,11 +262,11 @@ impl Planner { self } - // /// Upload a validator definition in this transaction. - // pub fn validator_definition(&mut self, new_validator: validator::Definition) -> &mut Self { - // self.action(ActionPlan::ValidatorDefinition(new_validator.into())); - // self - // } + /// Upload a validator definition in this transaction. + pub fn validator_definition(&mut self, new_validator: validator::Definition) -> &mut Self { + self.action(ActionPlan::ValidatorDefinition(new_validator)); + self + } /// Submit a new governance proposal in this transaction. pub fn proposal_submit(&mut self, proposal: Proposal, deposit_amount: Amount) -> &mut Self { @@ -259,19 +287,19 @@ impl Planner { } /// Claim a governance proposal deposit in this transaction. - // pub fn proposal_deposit_claim( - // &mut self, - // proposal: u64, - // deposit_amount: Amount, - // outcome: Outcome<()>, - // ) -> &mut Self { - // self.action(ActionPlan::ProposalDepositClaim(ProposalDepositClaim { - // proposal, - // deposit_amount, - // outcome, - // })); - // self - // } + pub fn proposal_deposit_claim( + &mut self, + proposal: u64, + deposit_amount: Amount, + outcome: proposal::Outcome<()>, + ) -> &mut Self { + self.action(ActionPlan::ProposalDepositClaim(ProposalDepositClaim { + proposal, + deposit_amount, + outcome, + })); + self + } /// Cast a validator vote in this transaction. pub fn validator_vote(&mut self, vote: ValidatorVote) -> &mut Self { @@ -279,26 +307,28 @@ impl Planner { self } - // /// Vote with all possible vote weight on a given proposal. - // pub fn delegator_vote( - // &mut self, - // proposal: u64, - // start_block_height: u64, - // start_position: tct::Position, - // start_rate_data: BTreeMap, - // vote: Vote, - // ) -> &mut Self { - // self.vote_intents.insert( - // proposal, - // VoteIntent { - // start_position, - // start_block_height, - // vote, - // rate_data: start_rate_data, - // }, - // ); - // self - // } + /// Vote with all possible vote weight on a given proposal. + /// + /// Voting twice on the same proposal in the same planner will overwrite the previous vote. + pub fn delegator_vote( + &mut self, + proposal: u64, + start_block_height: u64, + start_position: tct::Position, + start_rate_data: BTreeMap, + vote: Vote, + ) -> &mut Self { + self.vote_intents.insert( + proposal, + VoteIntent { + start_position, + start_block_height, + vote, + rate_data: start_rate_data, + }, + ); + self + } /// Vote with a specific positioned note in the transaction. /// @@ -337,11 +367,6 @@ impl Planner { self } - /// Add spends and change outputs as required to balance the transaction, using the view service - /// provided to supply the notes and other information. - /// - /// Clears the contents of the planner, which can be re-used. - /// Add spends and change outputs as required to balance the transaction, using the spendable /// notes provided. It is the caller's responsibility to ensure that the notes are the result of /// collected responses to the requests generated by an immediately preceding call to @@ -352,10 +377,9 @@ impl Planner { &mut self, chain_params: &ChainParameters, fmd_params: &FmdParameters, - fvk: &FullViewingKey, - source: AddressIndex, spendable_notes: Vec, - _votable_notes: Vec>, + votable_notes: Vec>, + self_address: Address, ) -> anyhow::Result { // Fill in the chain id based on the view service self.plan.chain_id = chain_params.chain_id.clone(); @@ -366,67 +390,88 @@ impl Planner { } // Add the required votes to the planner - // for ( - // records, - // ( - // proposal, - // VoteIntent { - // start_position, - // vote, - // rate_data, - // .. - // }, - // ), - // ) in votable_notes - // .into_iter() - // .chain(std::iter::repeat(vec![])) // Chain with infinite repeating no notes, so the zip doesn't stop early - // .zip(mem::take(&mut self.vote_intents).into_iter()) - // { - // if records.is_empty() { - // // If there are no notes to vote with, return an error, because otherwise the user - // // would compose a transaction that would not satisfy their intention, and would - // // silently eat the fee. - // anyhow::bail!( - // "can't vote on proposal {} because no delegation notes were staked when voting started", - // proposal - // ); - // } - // - // for (record, identity_key) in records { - // // Vote with precisely this note on the proposal, computing the correct exchange - // // rate for self-minted vote receipt tokens using the exchange rate of the validator - // // at voting start time - // let unbonded_amount = rate_data - // .get(&identity_key) - // .ok_or_else(|| anyhow!("missing rate data for note"))? - // .unbonded_amount(record.note.amount().into()) - // .into(); - // - // // If the delegation token is unspent, "roll it over" by spending it (this will - // // result in change sent back to us). This unlinks nullifiers used for voting on - // // multiple non-overlapping proposals, increasing privacy. - // if record.height_spent.is_none() { - // self.spend(record.note.clone(), record.position); - // } - // - // self.delegator_vote_precise( - // proposal, - // start_position, - // vote, - // record.note, - // record.position, - // unbonded_amount, - // ); - // } - // } + for ( + records, + ( + proposal, + VoteIntent { + start_position, + vote, + rate_data, + .. + }, + ), + ) in votable_notes + .into_iter() + .chain(std::iter::repeat(vec![])) // Chain with infinite repeating no notes, so the zip doesn't stop early + .zip(mem::take(&mut self.vote_intents).into_iter()) + { + // Keep track of whether we successfully could vote on this proposal + let mut voted = false; + + for (record, identity_key) in records { + // Vote with precisely this note on the proposal, computing the correct exchange + // rate for self-minted vote receipt tokens using the exchange rate of the validator + // at voting start time. If the validator was not active at the start of the + // proposal, the vote will be rejected by stateful verification, so skip the note + // and continue to the next one. + let Some(rate_data) = rate_data.get(&identity_key) else { + continue; + }; + let unbonded_amount = rate_data + .unbonded_amount(record.note.amount().value()) + .into(); + + // If the delegation token is unspent, "roll it over" by spending it (this will + // result in change sent back to us). This unlinks nullifiers used for voting on + // multiple non-overlapping proposals, increasing privacy. + if record.height_spent.is_none() { + self.spend(record.note.clone(), record.position); + } + + self.delegator_vote_precise( + proposal, + start_position, + vote, + record.note, + record.position, + unbonded_amount, + ); + + voted = true; + } + + if !voted { + // If there are no notes to vote with, return an error, because otherwise the user + // would compose a transaction that would not satisfy their intention, and would + // silently eat the fee. + return Err(anyhow!( + "can't vote on proposal {} because no delegation notes were staked to an active validator when voting started", + proposal + )); + } + } // For any remaining provided balance, make a single change note for each - let self_address = fvk.incoming().payment_address(source).0; for value in self.balance.provided().collect::>() { self.output(value, self_address); } + // All actions have now been added, so check to make sure that you don't build and submit an + // empty transaction + if self.plan.actions.is_empty() { + anyhow::bail!("planned transaction would be empty, so should not be submitted"); + } + + // Now the transaction should be fully balanced, unless we didn't have enough to spend + if !self.balance.is_zero() { + anyhow::bail!( + "balance is non-zero after attempting to balance transaction: {:?}", + self.balance + ); + } + // If there are outputs, we check that a memo has been added. If not, we add a default memo. if self.plan.num_outputs() > 0 && self.plan.memo_plan.is_none() { self.memo(MemoPlaintext::default()) @@ -440,14 +485,6 @@ impl Planner { self.plan .add_all_clue_plans(&mut self.rng, precision_bits.into()); - // Now the transaction should be fully balanced, unless we didn't have enough to spend - if !self.balance.is_zero() { - anyhow::bail!( - "balance is non-zero after attempting to balance transaction: {:?}", - self.balance - ); - } - // Clear the planner and pull out the plan to return self.balance = Balance::zero(); self.vote_intents = BTreeMap::new(); diff --git a/crates/wasm/src/storage.rs b/crates/wasm/src/storage.rs new file mode 100644 index 0000000000..0070873149 --- /dev/null +++ b/crates/wasm/src/storage.rs @@ -0,0 +1,172 @@ +use crate::error::WasmResult; +use crate::note_record::SpendableNoteRecord; +use indexed_db_futures::prelude::OpenDbRequest; +use indexed_db_futures::{IdbDatabase, IdbQuerySource}; +use penumbra_asset::asset::{DenomMetadata, Id}; +use penumbra_proto::core::chain::v1alpha1::{ChainParameters, FmdParameters}; +use penumbra_proto::core::crypto::v1alpha1::StateCommitment; +use penumbra_proto::view::v1alpha1::{NotesRequest, SwapRecord}; +use penumbra_proto::DomainType; +use penumbra_sct::Nullifier; +use penumbra_shielded_pool::{note, Note}; + +pub struct IndexedDBStorage { + db: IdbDatabase, +} + +impl IndexedDBStorage { + pub async fn new() -> WasmResult { + let db_req: OpenDbRequest = IdbDatabase::open_u32("penumbra", 12)?; + + let db: IdbDatabase = db_req.into_future().await?; + + Ok(IndexedDBStorage { db }) + } + + pub async fn get_notes(&self, request: NotesRequest) -> WasmResult> { + let idb_tx = self.db.transaction_on_one("spendable_notes")?; + let store = idb_tx.object_store("spendable_notes")?; + + let values = store.get_all()?.await?; + + let notes: Vec = values + .into_iter() + .map(|js_value| serde_wasm_bindgen::from_value(js_value).ok()) + .filter_map(|note_option| { + note_option.and_then(|note: SpendableNoteRecord| match request.asset_id.clone() { + Some(asset_id) => { + if note.note.asset_id() == asset_id.try_into().expect("Invalid asset id") + && note.height_spent.is_none() + { + Some(note) + } else { + None + } + } + None => Some(note), + }) + }) + .collect(); + + Ok(notes) + } + + pub async fn get_asset(&self, id: &Id) -> WasmResult> { + let tx = self.db.transaction_on_one("assets")?; + let store = tx.object_store("assets")?; + + Ok(store + .get_owned(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + id.to_proto().inner, + ))? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + + pub async fn get_note( + &self, + commitment: ¬e::StateCommitment, + ) -> WasmResult> { + let tx = self.db.transaction_on_one("spendable_notes")?; + let store = tx.object_store("spendable_notes")?; + + Ok(store + .get_owned(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + commitment.to_proto().inner, + ))? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + + pub async fn get_note_by_nullifier( + &self, + nullifier: &Nullifier, + ) -> WasmResult> { + let tx = self.db.transaction_on_one("spendable_notes")?; + let store = tx.object_store("spendable_notes")?; + + Ok(store + .index("nullifier")? + .get_owned(&base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + nullifier.to_proto().inner, + ))? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + + pub async fn store_advice(&self, note: Note) -> WasmResult<()> { + let tx = self.db.transaction_on_one("notes")?; + let store = tx.object_store("notes")?; + + let note_proto: penumbra_proto::core::crypto::v1alpha1::Note = note.clone().try_into()?; + let note_js = serde_wasm_bindgen::to_value(¬e_proto)?; + + let commitment_proto = note.commit().to_proto(); + + let commitment_js = serde_wasm_bindgen::to_value(&commitment_proto)?; + + store.put_key_val_owned(commitment_js, ¬e_js)?; + + Ok(()) + } + + pub async fn read_advice(&self, commitment: note::StateCommitment) -> WasmResult> { + let tx = self.db.transaction_on_one("notes")?; + let store = tx.object_store("notes")?; + + let commitment_proto = commitment.to_proto(); + + let commitment_js = serde_wasm_bindgen::to_value(&commitment_proto)?; + + Ok(store + .get_owned(commitment_js)? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + + pub async fn get_chain_parameters(&self) -> WasmResult> { + let tx = self.db.transaction_on_one("chain_parameters")?; + let store = tx.object_store("chain_parameters")?; + + Ok(store + .get_owned("chain_parameters")? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + + pub async fn get_fmd_parameters(&self) -> WasmResult> { + let tx = self.db.transaction_on_one("fmd_parameters")?; + let store = tx.object_store("fmd_parameters")?; + + Ok(store + .get_owned("fmd")? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + + pub async fn get_swap_by_commitment( + &self, + swap_commitment: StateCommitment, + ) -> WasmResult> { + let tx = self.db.transaction_on_one("swaps")?; + let store = tx.object_store("swaps")?; + + Ok(store + .get_owned(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + swap_commitment.inner, + ))? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } +} diff --git a/crates/wasm/src/tx.rs b/crates/wasm/src/tx.rs index 895720e230..12dbe4634c 100644 --- a/crates/wasm/src/tx.rs +++ b/crates/wasm/src/tx.rs @@ -1,146 +1,257 @@ -use std::convert::TryInto; -use std::str::FromStr; - -use penumbra_chain::params::{ChainParameters, FmdParameters}; -use penumbra_keys::{Address, FullViewingKey}; - -use penumbra_keys::keys::{AddressIndex, SpendKey}; +use crate::error::WasmResult; +use crate::storage::IndexedDBStorage; +use crate::view_server::{load_tree, StoredTree}; +use penumbra_keys::keys::SpendKey; +use penumbra_keys::FullViewingKey; +use penumbra_proto::core::transaction::v1alpha1::{TransactionPerspective, TransactionView}; use penumbra_tct::{Proof, StateCommitment, Tree}; use penumbra_transaction::plan::TransactionPlan; use penumbra_transaction::{AuthorizationData, Transaction, WitnessData}; use rand_core::OsRng; use serde::{Deserialize, Serialize}; +use serde_wasm_bindgen::Error; +use std::collections::{BTreeMap, BTreeSet}; +use std::convert::TryInto; +use std::str::FromStr; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; -use crate::note_record::SpendableNoteRecord; -use crate::planner::Planner; -use crate::utils; -use crate::view_server::{load_tree, StoredTree}; -use web_sys::console as web_console; - #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SendTx { - notes: Vec, - chain_parameters: penumbra_proto::core::chain::v1alpha1::ChainParameters, - fmd_parameters: penumbra_proto::core::chain::v1alpha1::FmdParameters, +pub struct TxInfoResponse { + txp: TransactionPerspective, + txv: TransactionView, } -#[wasm_bindgen] -pub fn send_plan( - full_viewing_key: &str, - value_js: JsValue, - dest_address: &str, - view_service_data: JsValue, -) -> JsValue { - utils::set_panic_hook(); - web_console::log_1(&value_js); - - let value: penumbra_proto::core::crypto::v1alpha1::Value = - serde_wasm_bindgen::from_value(value_js).expect("able to parse send plan's Value from JS"); - - let address = - Address::from_str(dest_address).expect("send plan's destination address is valid"); - let mut planner = Planner::new(OsRng); - planner.fee(Default::default()); - planner.output( - value.try_into().expect("encoded protobuf Value is valid"), - address, - ); - - let fvk = FullViewingKey::from_str(full_viewing_key) - .expect("the provided string is a valid FullViewingKey"); - - let send_tx: SendTx = serde_wasm_bindgen::from_value(view_service_data) - .expect("able to parse send plan's SendTx from JS"); - - let chain_params: ChainParameters = send_tx - .chain_parameters - .try_into() - .expect("encoded protobuf ChainParameters is valid"); - let fmd_params: FmdParameters = send_tx - .fmd_parameters - .try_into() - .expect("encoded protobuf FmdParameters is valid"); - - let plan = planner - .plan_with_spendable_and_votable_notes( - &chain_params, - &fmd_params, - &fvk, - AddressIndex::from(0u32), - send_tx.notes, - Default::default(), - ) - .expect("valid send transaction parameters were provided"); +impl TxInfoResponse { + pub fn new(txp: TransactionPerspective, txv: TransactionView) -> TxInfoResponse { + Self { txp, txv } + } +} - serde_wasm_bindgen::to_value(&plan).expect("able to serialize send plan to JS") +/// encode transaction to bytes +/// Arguments: +/// transaction: `penumbra_transaction::Transaction` +/// Returns: `` +#[wasm_bindgen] +pub fn encode_tx(transaction: JsValue) -> WasmResult { + let tx: Transaction = serde_wasm_bindgen::from_value(transaction)?; + let tx_encoding: Vec = tx.try_into()?; + let result = serde_wasm_bindgen::to_value(&tx_encoding)?; + Ok(result) } +/// decode base64 bytes to transaction +/// Arguments: +/// tx_bytes: `base64 String` +/// Returns: `penumbra_transaction::Transaction` #[wasm_bindgen] -pub fn encode_tx(transaction: JsValue) -> JsValue { - utils::set_panic_hook(); - let tx: Transaction = serde_wasm_bindgen::from_value(transaction) - .expect("able to deserialize transaction from JS"); - let tx_encoding: Vec = tx.try_into().expect("able to encode transaction to bytes"); - serde_wasm_bindgen::to_value(&tx_encoding) - .expect("able to serialize transaction encoding to JS") +pub fn decode_tx(tx_bytes: &str) -> WasmResult { + let tx_vec: Vec = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, tx_bytes)?; + let transaction: Transaction = Transaction::try_from(tx_vec)?; + let result = serde_wasm_bindgen::to_value(&transaction)?; + Ok(result) } +/// TODO: Deprecated. Still used in `penumbra-zone/wallet`, remove when migration is complete. +/// In the future, this function will be split into separate functions +/// - sign the transaction +/// - build transaction +/// - get wittness #[wasm_bindgen] pub fn build_tx( spend_key_str: &str, full_viewing_key: &str, transaction_plan: JsValue, stored_tree: JsValue, -) -> JsValue { - utils::set_panic_hook(); - let plan: TransactionPlan = serde_wasm_bindgen::from_value(transaction_plan) - .expect("able to deserialize transaction plan from JS"); +) -> Result { + let plan: TransactionPlan = serde_wasm_bindgen::from_value(transaction_plan)?; - let fvk = FullViewingKey::from_str(full_viewing_key) - .expect("the provided string is a valid FullViewingKey"); + let fvk = FullViewingKey::from_str(full_viewing_key.as_ref()) + .expect("The provided string is not a valid FullViewingKey"); - let auth_data = sign_plan(spend_key_str, plan.clone()); + let auth_data = sign_plan(spend_key_str, plan.clone())?; - let stored_tree: StoredTree = serde_wasm_bindgen::from_value(stored_tree) - .expect("able to deserialize stored tree from JS"); + let stored_tree: StoredTree = + serde_wasm_bindgen::from_value(stored_tree).expect("able to parse StoredTree from JS"); let nct = load_tree(stored_tree); - let witness_data = witness(nct, plan.clone()); + let witness_data = witness(nct, plan.clone())?; + + let tx = build_transaction(&fvk, plan.clone(), auth_data, witness_data)?; - let tx = build_transaction(&fvk, plan.clone(), auth_data, witness_data); + serde_wasm_bindgen::to_value(&tx) +} + +/// Get transaction view, transaction perspective +/// Arguments: +/// full_viewing_key: `bech32 String` +/// tx: `pbt::Transaction` +/// Returns: `TxInfoResponse` +#[wasm_bindgen] +pub async fn transaction_info(full_viewing_key: &str, tx: JsValue) -> Result { + let transaction = serde_wasm_bindgen::from_value(tx)?; + let response = transaction_info_inner(full_viewing_key, transaction).await?; - serde_wasm_bindgen::to_value(&tx).expect("able to serialize transaction to JS") + serde_wasm_bindgen::to_value(&response) } -pub fn sign_plan(spend_key_str: &str, transaction_plan: TransactionPlan) -> AuthorizationData { - let spend_key = SpendKey::from_str(spend_key_str).expect("spend key is valid"); +/// deprecated +pub async fn transaction_info_inner( + full_viewing_key: &str, + tx: Transaction, +) -> WasmResult { + let storage = IndexedDBStorage::new().await?; + + let fvk = FullViewingKey::from_str(full_viewing_key)?; - transaction_plan.authorize(OsRng, &spend_key) + // First, create a TxP with the payload keys visible to our FVK and no other data. + let mut txp = penumbra_transaction::TransactionPerspective { + payload_keys: tx + .payload_keys(&fvk) + .expect("Error generating payload keys"), + ..Default::default() + }; + + // Next, extend the TxP with the openings of commitments known to our view server + // but not included in the transaction body, for instance spent notes or swap claim outputs. + for action in tx.actions() { + use penumbra_transaction::Action; + match action { + Action::Spend(spend) => { + let nullifier = spend.body.nullifier; + // An error here indicates we don't know the nullifier, so we omit it from the Perspective. + if let Some(spendable_note_record) = + storage.get_note_by_nullifier(&nullifier).await? + { + txp.spend_nullifiers + .insert(nullifier, spendable_note_record.note.clone()); + } + } + Action::SwapClaim(claim) => { + let output_1_record = storage + .get_note(&claim.body.output_1_commitment) + .await? + .expect("Error generating TxP: SwapClaim output 1 commitment not found"); + + let output_2_record = storage + .get_note(&claim.body.output_2_commitment) + .await? + .expect("Error generating TxP: SwapClaim output 2 commitment not found"); + + txp.advice_notes + .insert(claim.body.output_1_commitment, output_1_record.note.clone()); + txp.advice_notes + .insert(claim.body.output_2_commitment, output_2_record.note.clone()); + } + _ => {} + } + } + + // Now, generate a stub TxV from our minimal TxP, and inspect it to see what data we should + // augment the minimal TxP with to provide additional context (e.g., filling in denoms for + // visible asset IDs). + let min_view = tx.view_from_perspective(&txp); + let mut address_views = BTreeMap::new(); + let mut asset_ids = BTreeSet::new(); + for action_view in min_view.action_views() { + use penumbra_dex::{swap::SwapView, swap_claim::SwapClaimView}; + use penumbra_transaction::view::action_view::{ + ActionView, DelegatorVoteView, OutputView, SpendView, + }; + match action_view { + ActionView::Spend(SpendView::Visible { note, .. }) => { + let address = note.address(); + address_views.insert(address, fvk.view_address(address)); + asset_ids.insert(note.asset_id()); + } + ActionView::Output(OutputView::Visible { note, .. }) => { + let address = note.address(); + address_views.insert(address, fvk.view_address(address)); + asset_ids.insert(note.asset_id()); + } + ActionView::Swap(SwapView::Visible { swap_plaintext, .. }) => { + let address = swap_plaintext.claim_address; + address_views.insert(address, fvk.view_address(address)); + asset_ids.insert(swap_plaintext.trading_pair.asset_1()); + asset_ids.insert(swap_plaintext.trading_pair.asset_2()); + } + ActionView::SwapClaim(SwapClaimView::Visible { + output_1, output_2, .. + }) => { + // Both will be sent to the same address so this only needs to be added once + let address = output_1.address(); + address_views.insert(address, fvk.view_address(address)); + asset_ids.insert(output_1.asset_id()); + asset_ids.insert(output_2.asset_id()); + } + ActionView::DelegatorVote(DelegatorVoteView::Visible { note, .. }) => { + let address = note.address(); + address_views.insert(address, fvk.view_address(address)); + asset_ids.insert(note.asset_id()); + } + _ => {} + } + } + + // Now, extend the TxP with information helpful to understand the data it can view: + + let mut denoms = Vec::new(); + + for id in asset_ids { + if let Some(denom) = storage.get_asset(&id).await? { + denoms.push(denom.clone()); + } + } + + txp.denoms.extend(denoms.into_iter()); + + txp.address_views = address_views.into_values().collect(); + + // Finally, compute the full TxV from the full TxP: + let txv = tx.view_from_perspective(&txp); + + let txp_proto = TransactionPerspective::try_from(txp)?; + let txv_proto = TransactionView::try_from(txv)?; + + let response = TxInfoResponse { + txp: txp_proto, + txv: txv_proto, + }; + Ok(response) } -pub fn build_transaction( +fn sign_plan( + spend_key_str: &str, + transaction_plan: TransactionPlan, +) -> WasmResult { + let spend_key = SpendKey::from_str(spend_key_str)?; + let auth_data = transaction_plan.authorize(OsRng, &spend_key); + Ok(auth_data) +} + +fn build_transaction( fvk: &FullViewingKey, plan: TransactionPlan, auth_data: AuthorizationData, witness_data: WitnessData, -) -> Transaction { - plan.build(fvk, witness_data) - .expect("valid transaction plan was provided") - .authorize(&mut OsRng, &auth_data) - .expect("valid authorization data was provided") +) -> WasmResult { + let tx = plan + .build(fvk, witness_data)? + .authorize(&mut OsRng, &auth_data)?; + + Ok(tx) } -fn witness(nct: Tree, plan: TransactionPlan) -> WitnessData { +fn witness(nct: Tree, plan: TransactionPlan) -> WasmResult { let note_commitments: Vec = plan .spend_plans() .filter(|plan| plan.note.amount() != 0u64.into()) .map(|spend| spend.note.commit().into()) .chain( plan.swap_claim_plans() - .map(|swap_claim| swap_claim.swap_plaintext.swap_commitment().into()), + .map(|swap_claim| swap_claim.swap_plaintext.swap_commitment()), ) .collect(); @@ -173,5 +284,5 @@ fn witness(nct: Tree, plan: TransactionPlan) -> WitnessData { { witness_data.add_proof(nc, Proof::dummy(&mut OsRng, nc)); } - witness_data + Ok(witness_data) } diff --git a/crates/wasm/src/utils.rs b/crates/wasm/src/utils.rs index b1d7929dc9..834396e978 100644 --- a/crates/wasm/src/utils.rs +++ b/crates/wasm/src/utils.rs @@ -1,10 +1,21 @@ +use crate::error::WasmResult; +use penumbra_proto::DomainType; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; + pub fn set_panic_hook() { - // When the `console_error_panic_hook` feature is enabled, we can call the - // `set_panic_hook` function at least once during initialization, and then - // we will get better error messages if our code ever panics. - // - // For more details see - // https://github.com/rustwasm/console_error_panic_hook#readme #[cfg(feature = "console_error_panic_hook")] console_error_panic_hook::set_once(); } + +/// decode SCT root +/// Arguments: +/// tx_bytes: `HEX string` +/// Returns: `penumbra_tct::Root` +#[wasm_bindgen] +pub fn decode_nct_root(tx_bytes: &str) -> WasmResult { + let tx_vec: Vec = hex::decode(tx_bytes)?; + let root = penumbra_tct::Root::decode(tx_vec.as_slice())?; + let result = serde_wasm_bindgen::to_value(&root)?; + Ok(result) +} diff --git a/crates/wasm/src/view_server.rs b/crates/wasm/src/view_server.rs index a24a0b010d..4e7a4e1003 100644 --- a/crates/wasm/src/view_server.rs +++ b/crates/wasm/src/view_server.rs @@ -1,30 +1,24 @@ -use indexed_db_futures::prelude::OpenDbRequest; -use indexed_db_futures::{IdbDatabase, IdbQuerySource}; +use crate::error::WasmResult; +use crate::note_record::SpendableNoteRecord; +use crate::storage::IndexedDBStorage; +use crate::swap_record::SwapRecord; use penumbra_asset::asset::{DenomMetadata, Id}; use penumbra_compact_block::{CompactBlock, StatePayload}; use penumbra_dex::lp::position::Position; use penumbra_dex::lp::LpNft; use penumbra_keys::FullViewingKey; -use penumbra_proto::core::transaction::v1alpha1::{TransactionPerspective, TransactionView}; -use penumbra_proto::DomainType; use penumbra_sct::Nullifier; use penumbra_shielded_pool::note; use penumbra_tct as tct; use penumbra_tct::Witness::*; -use penumbra_transaction::Transaction; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use serde_wasm_bindgen::Error; use std::convert::TryInto; use std::{collections::BTreeMap, str::FromStr}; use tct::storage::{StoreCommitment, StoreHash, StoredPosition, Updates}; use tct::{Forgotten, Tree}; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; -use web_sys::console as web_console; - -use crate::note_record::SpendableNoteRecord; -use crate::swap_record::SwapRecord; -use crate::utils; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StoredTree { @@ -58,18 +52,6 @@ impl ScanBlockResult { } } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TxInfoResponse { - txp: TransactionPerspective, - txv: TransactionView, -} - -impl TxInfoResponse { - pub fn new(txp: TransactionPerspective, txv: TransactionView) -> TxInfoResponse { - Self { txp, txv } - } -} - #[wasm_bindgen] pub struct ViewServer { latest_height: u64, @@ -79,23 +61,22 @@ pub struct ViewServer { notes_by_nullifier: BTreeMap, swaps: BTreeMap, denoms: BTreeMap, - nct: penumbra_tct::Tree, + nct: Tree, + storage: IndexedDBStorage, } #[wasm_bindgen] impl ViewServer { #[wasm_bindgen(constructor)] - pub fn new(full_viewing_key: &str, epoch_duration: u64, stored_tree: JsValue) -> ViewServer { - utils::set_panic_hook(); - let fvk = FullViewingKey::from_str(full_viewing_key) - .expect("the provided string is a valid FullViewingKey"); - - let stored_tree: StoredTree = serde_wasm_bindgen::from_value(stored_tree) - .expect("able to deserialize stored tree from JS"); - + pub async fn new( + full_viewing_key: &str, + epoch_duration: u64, + stored_tree: JsValue, + ) -> WasmResult { + let fvk = FullViewingKey::from_str(full_viewing_key)?; + let stored_tree: StoredTree = serde_wasm_bindgen::from_value(stored_tree)?; let tree = load_tree(stored_tree); - - Self { + let view_server = Self { latest_height: u64::MAX, fvk, epoch_duration, @@ -104,144 +85,77 @@ impl ViewServer { denoms: Default::default(), nct: tree, swaps: Default::default(), - } + storage: IndexedDBStorage::new().await?, + }; + Ok(view_server) } + /// Scans block for notes, swaps + /// This method does not return SCT updates (nct_updates). + /// Although we can make it do if we want to save SCT updates after each blob is processed + /// Arguments: + /// compact_block: `v1alpha1::CompactBlock` + /// Returns: `ScanBlockResult` #[wasm_bindgen] - pub fn scan_block( + pub async fn scan_block(&mut self, compact_block: JsValue) -> Result { + let result = self.scan_block_inner(compact_block).await?; + serde_wasm_bindgen::to_value(&result) + } + + /// get SCT state updates + /// This method is necessary because we save SCT updates to indexedDB once every 1000 blocks + /// rather than after each block + /// Arguments: + /// last_position: `Option` + /// last_forgotten: `Option` + /// Returns: `ScanBlockResult` + #[wasm_bindgen] + pub fn get_updates( &mut self, - compact_block: JsValue, last_position: JsValue, last_forgotten: JsValue, - ) -> JsValue { - utils::set_panic_hook(); - - let stored_position: Option = serde_wasm_bindgen::from_value(last_position) - .expect("able to deserialize stored position from JS"); - let stored_forgotten: Option = serde_wasm_bindgen::from_value(last_forgotten) - .expect("able to deserialize stored forgotten from JS"); - - let block_proto: penumbra_proto::core::chain::v1alpha1::CompactBlock = - serde_wasm_bindgen::from_value(compact_block) - .expect("able to deserialize block from JS"); - - let block: CompactBlock = block_proto - .try_into() - .expect("able to convert block to CompactBlock"); - - let mut new_notes = Vec::new(); - let mut new_swaps: Vec = Vec::new(); - - for state_payload in block.state_payloads { - let clone_payload = state_payload.clone(); - - match state_payload { - StatePayload::Note { note: payload, .. } => { - match payload.trial_decrypt(&self.fvk) { - Some(note) => { - let note_position = self - .nct - .insert(Keep, payload.note_commitment) - .expect("able to insert note commitment into tree"); - - let source = clone_payload.source().cloned().unwrap_or_default(); - let nullifier = Nullifier::derive( - self.fvk.nullifier_key(), - note_position, - clone_payload.commitment(), - ); - let address_index = self - .fvk - .incoming() - .index_for_diversifier(note.diversifier()); - - web_console::log_1(&"Found new notes".into()); - - let note_record = SpendableNoteRecord { - note_commitment: *clone_payload.commitment(), - height_spent: None, - height_created: block.height, - note: note.clone(), - address_index, - nullifier, - position: note_position, - source, - }; - new_notes.push(note_record.clone()); - self.notes - .insert(payload.note_commitment, note_record.clone()); - self.notes_by_nullifier - .insert(nullifier, note_record.clone()); - } - None => { - self.nct - .insert(Forget, payload.note_commitment) - .expect("able to insert note commitment into tree"); - } - } - } - StatePayload::Swap { swap: payload, .. } => { - match payload.trial_decrypt(&self.fvk) { - Some(swap) => { - let swap_position = self - .nct - .insert(Keep, payload.commitment) - .expect("able to insert swap commitment into tree"); - - let batch_data = block - .swap_outputs - .get(&swap.trading_pair) - .ok_or_else(|| anyhow::anyhow!("server gave invalid compact block")) - .expect("able to get swap output from compact block"); - - let source = clone_payload.source().cloned().unwrap_or_default(); - let nullifier = Nullifier::derive( - self.fvk.nullifier_key(), - swap_position, - clone_payload.commitment(), - ); + ) -> Result { + let result = self.get_updates_inner(last_position, last_forgotten)?; + serde_wasm_bindgen::to_value(&result) + } - let swap_record = SwapRecord { - swap_commitment: *clone_payload.commitment(), - swap: swap.clone(), - position: swap_position, - nullifier, - source, - output_data: *batch_data, - height_claimed: None, - }; - new_swaps.push(swap_record.clone()); - self.swaps.insert(payload.commitment, swap_record); - } - None => { - self.nct - .insert(Forget, payload.commitment) - .expect("able to insert swap commitment into tree"); - } - } - } - StatePayload::RolledUp(commitment) => { - if self.notes.contains_key(&commitment) { - // This is a note we anticipated, so retain its auth path. - self.nct - .insert(Keep, commitment) - .expect("able to insert our note commitment into tree"); - } else { - // This is someone else's note. - self.nct - .insert(Forget, commitment) - .expect("able to insert someone else's note commitment into tree"); - } - } - } - } + /// get SCT root + /// SCT root can be compared with the root obtained by GRPC and verify that there is no divergence + /// Returns: `Root` + #[wasm_bindgen] + pub fn get_nct_root(&mut self) -> Result { + let root = self.nct.root(); + serde_wasm_bindgen::to_value(&root) + } - self.nct.end_block().expect("able to end block"); - if block.epoch_root.is_some() { - self.nct.end_epoch().expect("able to end epoch"); - } + /// get LP NFT asset + /// Arguments: + /// position_value: `lp::position::Position` + /// position_state_value: `lp::position::State` + /// Returns: `DenomMetadata` + #[wasm_bindgen] + pub fn get_lpnft_asset( + &mut self, + position_value: JsValue, + position_state_value: JsValue, + ) -> Result { + let position: Position = serde_wasm_bindgen::from_value(position_value)?; + let position_state = serde_wasm_bindgen::from_value(position_state_value)?; + let lp_nft = LpNft::new(position.id(), position_state); + let denom = lp_nft.denom(); + serde_wasm_bindgen::to_value(&denom) + } +} - self.latest_height = block.height; +impl ViewServer { + pub fn get_updates_inner( + &mut self, + last_position: JsValue, + last_forgotten: JsValue, + ) -> WasmResult { + let stored_position: Option = + serde_wasm_bindgen::from_value(last_position)?; + let stored_forgotten: Option = serde_wasm_bindgen::from_value(last_forgotten)?; let nct_updates: Updates = self .nct @@ -254,24 +168,20 @@ impl ViewServer { let result = ScanBlockResult { height: self.latest_height, nct_updates, - new_notes, - new_swaps, + new_notes: self.notes.clone().into_values().collect(), + new_swaps: self.swaps.clone().into_values().collect(), }; - - serde_wasm_bindgen::to_value(&result).expect("able to serialize ScanBlockResult to JS") + Ok(result) } - #[wasm_bindgen] - pub fn scan_block_without_updates(&mut self, compact_block: JsValue) -> JsValue { - utils::set_panic_hook(); - + pub async fn scan_block_inner( + &mut self, + compact_block: JsValue, + ) -> WasmResult { let block_proto: penumbra_proto::core::chain::v1alpha1::CompactBlock = - serde_wasm_bindgen::from_value(compact_block) - .expect("able to deserialize block from JS"); + serde_wasm_bindgen::from_value(compact_block)?; - let block: CompactBlock = block_proto - .try_into() - .expect("able to convert block to CompactBlock"); + let block: CompactBlock = block_proto.try_into()?; // Newly detected spendable notes. let mut new_notes = Vec::new(); @@ -285,10 +195,7 @@ impl ViewServer { StatePayload::Note { note: payload, .. } => { match payload.trial_decrypt(&self.fvk) { Some(note) => { - let note_position = self - .nct - .insert(Keep, payload.note_commitment) - .expect("able to insert note commitment into tree"); + let note_position = self.nct.insert(Keep, payload.note_commitment)?; let source = clone_payload.source().cloned().unwrap_or_default(); let nullifier = Nullifier::derive( @@ -301,8 +208,6 @@ impl ViewServer { .incoming() .index_for_diversifier(note.diversifier()); - web_console::log_1(&"Found new notes".into()); - let note_record = SpendableNoteRecord { note_commitment: *clone_payload.commitment(), height_spent: None, @@ -320,24 +225,18 @@ impl ViewServer { .insert(nullifier, note_record.clone()); } None => { - self.nct - .insert(Forget, payload.note_commitment) - .expect("able to insert note commitment into tree"); + self.nct.insert(Forget, payload.note_commitment)?; } } } StatePayload::Swap { swap: payload, .. } => { match payload.trial_decrypt(&self.fvk) { Some(swap) => { - let swap_position = self - .nct - .insert(Keep, payload.commitment) - .expect("able to insert swap commitment into tree"); - let batch_data = block - .swap_outputs - .get(&swap.trading_pair) - .ok_or_else(|| anyhow::anyhow!("server gave invalid compact block")) - .expect("able to get swap output from compact block"); + let swap_position = self.nct.insert(Keep, payload.commitment)?; + let batch_data = + block.swap_outputs.get(&swap.trading_pair).ok_or_else(|| { + anyhow::anyhow!("server gave invalid compact block") + })?; let source = clone_payload.source().cloned().unwrap_or_default(); let nullifier = Nullifier::derive( @@ -357,33 +256,74 @@ impl ViewServer { }; new_swaps.push(swap_record.clone()); self.swaps.insert(payload.commitment, swap_record); + + let batch_data = + block.swap_outputs.get(&swap.trading_pair).ok_or_else(|| { + anyhow::anyhow!("server gave invalid compact block") + })?; + + let (output_1, output_2) = swap.output_notes(batch_data); + + self.storage.store_advice(output_1).await?; + self.storage.store_advice(output_2).await?; } None => { - self.nct - .insert(Forget, payload.commitment) - .expect("able to insert swap commitment into tree"); + self.nct.insert(Forget, payload.commitment)?; } } } StatePayload::RolledUp(commitment) => { - if self.notes.contains_key(&commitment) { + if let std::collections::btree_map::Entry::Occupied(mut e) = + self.notes.entry(commitment) + { // This is a note we anticipated, so retain its auth path. - self.nct - .insert(Keep, commitment) - .expect("able to insert our note commitment into tree"); + + let advice_result = self.storage.read_advice(commitment).await?; + + match advice_result { + None => {} + Some(note) => { + let position = self.nct.insert(Keep, commitment)?; + + let address_index_1 = self + .fvk + .incoming() + .index_for_diversifier(note.diversifier()); + + let nullifier = Nullifier::derive( + self.fvk.nullifier_key(), + position, + &commitment, + ); + + let source = clone_payload.source().cloned().unwrap_or_default(); + + let spendable_note = SpendableNoteRecord { + note_commitment: note.commit(), + height_spent: Some(u64::MAX), + height_created: block.height, + note: note.clone(), + address_index: address_index_1, + nullifier, + position, + source, + }; + + e.insert(spendable_note.clone()); + new_notes.push(spendable_note.clone()); + } + } } else { // This is someone else's note. - self.nct - .insert(Forget, commitment) - .expect("able to insert someone else's note commitment into tree"); + self.nct.insert(Forget, commitment)?; } } } } - self.nct.end_block().expect("able to end block"); + self.nct.end_block()?; if block.epoch_root.is_some() { - self.nct.end_epoch().expect("able to end epoch"); + self.nct.end_epoch()?; } self.latest_height = block.height; @@ -394,249 +334,8 @@ impl ViewServer { new_notes, new_swaps, }; - - serde_wasm_bindgen::to_value(&result).expect("able to serialize ScanBlockResult to JS") - } - - pub fn get_updates(&mut self, last_position: JsValue, last_forgotten: JsValue) -> JsValue { - let stored_position: Option = serde_wasm_bindgen::from_value(last_position) - .expect("able to deserialize stored position from JS"); - let stored_forgotten: Option = serde_wasm_bindgen::from_value(last_forgotten) - .expect("able to deserialize stored forgotten from JS"); - - let nct_updates: Updates = self - .nct - .updates( - stored_position.unwrap_or_default(), - stored_forgotten.unwrap_or_default(), - ) - .collect::(); - - let result = ScanBlockResult { - height: self.latest_height, - nct_updates, - new_notes: self.notes.clone().into_values().collect(), - new_swaps: self.swaps.clone().into_values().collect(), - }; - serde_wasm_bindgen::to_value(&result).expect("able to serialize ScanBlockResult to JS") - } - - pub fn get_nct_root(&mut self) -> JsValue { - let root = self.nct.root(); - serde_wasm_bindgen::to_value(&root).expect("able to serialize root to JS") - } - - pub fn get_lpnft_asset( - &mut self, - position_value: JsValue, - position_state_value: JsValue, - ) -> JsValue { - let position: Position = serde_wasm_bindgen::from_value(position_value) - .expect("able to deserialize position from JS"); - let position_state = serde_wasm_bindgen::from_value(position_state_value) - .expect("able to deserialize position state from JS"); - - let lp_nft = LpNft::new(position.id(), position_state); - - let denom = lp_nft.denom(); - - serde_wasm_bindgen::to_value(&denom).expect("able to serialize denom to JS") - } -} - -#[wasm_bindgen] -pub async fn transaction_info(full_viewing_key: &str, tx: JsValue) -> JsValue { - let fvk = FullViewingKey::from_str(full_viewing_key) - .expect("the provided string is a valid FullViewingKey"); - - let transaction = serde_wasm_bindgen::from_value(tx).expect("able to deserialize tx from JS"); - let (txp, txv) = transaction_info_inner(fvk, transaction).await; - - let txp_proto = TransactionPerspective::try_from(txp).expect("able to convert to proto"); - let txv_proto = TransactionView::try_from(txv).expect("able to convert to proto"); - - let response = TxInfoResponse { - txp: txp_proto, - txv: txv_proto, - }; - serde_wasm_bindgen::to_value(&response).expect("able to serialize TxInfoResponse to JS") -} - -pub async fn transaction_info_inner( - fvk: FullViewingKey, - tx: Transaction, -) -> ( - penumbra_transaction::TransactionPerspective, - penumbra_transaction::TransactionView, -) { - utils::set_panic_hook(); - - // First, create a TxP with the payload keys visible to our FVK and no other data. - - let mut txp = penumbra_transaction::TransactionPerspective { - payload_keys: tx - .payload_keys(&fvk) - .expect("Error generating payload keys"), - ..Default::default() - }; - - // Next, extend the TxP with the openings of commitments known to our view server - // but not included in the transaction body, for instance spent notes or swap claim outputs. - for action in tx.actions() { - use penumbra_transaction::Action; - match action { - Action::Spend(spend) => { - let nullifier = spend.body.nullifier; - // An error here indicates we don't know the nullifier, so we omit it from the Perspective. - if let Some(spendable_note_record) = get_note_by_nullifier(&nullifier).await { - txp.spend_nullifiers - .insert(nullifier, spendable_note_record.note.clone()); - } - } - Action::SwapClaim(claim) => { - let output_1_record = get_note(&claim.body.output_1_commitment) - .await - .expect("Error generating TxP: SwapClaim output 1 commitment not found"); - - let output_2_record = get_note(&claim.body.output_2_commitment) - .await - .expect("Error generating TxP: SwapClaim output 2 commitment not found"); - - txp.advice_notes - .insert(claim.body.output_1_commitment, output_1_record.note.clone()); - txp.advice_notes - .insert(claim.body.output_2_commitment, output_2_record.note.clone()); - } - _ => {} - } - } - - // Now, generate a stub TxV from our minimal TxP, and inspect it to see what data we should - // augment the minimal TxP with to provide additional context (e.g., filling in denoms for - // visible asset IDs). - let min_view = tx.view_from_perspective(&txp); - let mut address_views = BTreeMap::new(); - let mut asset_ids = BTreeSet::new(); - for action_view in min_view.action_views() { - use penumbra_dex::{swap::SwapView, swap_claim::SwapClaimView}; - use penumbra_transaction::view::action_view::{ - ActionView, DelegatorVoteView, OutputView, SpendView, - }; - match action_view { - ActionView::Spend(SpendView::Visible { note, .. }) => { - let address = note.address(); - address_views.insert(address, fvk.view_address(address)); - asset_ids.insert(note.asset_id()); - } - ActionView::Output(OutputView::Visible { note, .. }) => { - let address = note.address(); - address_views.insert(address, fvk.view_address(address)); - asset_ids.insert(note.asset_id()); - } - ActionView::Swap(SwapView::Visible { swap_plaintext, .. }) => { - let address = swap_plaintext.claim_address; - address_views.insert(address, fvk.view_address(address)); - asset_ids.insert(swap_plaintext.trading_pair.asset_1()); - asset_ids.insert(swap_plaintext.trading_pair.asset_2()); - } - ActionView::SwapClaim(SwapClaimView::Visible { - output_1, output_2, .. - }) => { - // Both will be sent to the same address so this only needs to be added once - let address = output_1.address(); - address_views.insert(address, fvk.view_address(address)); - asset_ids.insert(output_1.asset_id()); - asset_ids.insert(output_2.asset_id()); - } - ActionView::DelegatorVote(DelegatorVoteView::Visible { note, .. }) => { - let address = note.address(); - address_views.insert(address, fvk.view_address(address)); - asset_ids.insert(note.asset_id()); - } - _ => {} - } + Ok(result) } - - // Now, extend the TxP with information helpful to understand the data it can view: - - let mut denoms = Vec::new(); - - for id in asset_ids { - if let Some(denom) = get_asset(&id).await { - denoms.push(denom.clone()); - } - } - - txp.denoms.extend(denoms.into_iter()); - - txp.address_views = address_views.into_values().collect(); - - // Finally, compute the full TxV from the full TxP: - let txv = tx.view_from_perspective(&txp); - - (txp, txv) -} - -pub async fn get_asset(id: &Id) -> Option { - let db_req: OpenDbRequest = IdbDatabase::open_u32("penumbra", 11).ok()?; - - let db: IdbDatabase = db_req.into_future().await.ok()?; - - let tx = db.transaction_on_one("assets").ok()?; - let store = tx.object_store("assets").ok()?; - - let value: Option = store - .get_owned(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - id.to_proto().inner, - )) - .ok()? - .await - .ok()?; - - serde_wasm_bindgen::from_value(value?).ok()? -} - -pub async fn get_note(commitment: ¬e::StateCommitment) -> Option { - let db_req: OpenDbRequest = IdbDatabase::open_u32("penumbra", 11).ok()?; - - let db: IdbDatabase = db_req.into_future().await.ok()?; - - let tx = db.transaction_on_one("spendable_notes").ok()?; - let store = tx.object_store("spendable_notes").ok()?; - - let value: Option = store - .get_owned(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - commitment.to_proto().inner, - )) - .ok()? - .await - .ok()?; - - serde_wasm_bindgen::from_value(value?).ok()? -} - -pub async fn get_note_by_nullifier(nullifier: &Nullifier) -> Option { - let db_req: OpenDbRequest = IdbDatabase::open_u32("penumbra", 11).ok()?; - - let db: IdbDatabase = db_req.into_future().await.ok()?; - - let tx = db.transaction_on_one("spendable_notes").ok()?; - let store = tx.object_store("spendable_notes").ok()?; - - let value: Option = store - .index("nullifier") - .ok()? - .get_owned(&base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - nullifier.to_proto().inner, - )) - .ok()? - .await - .ok()?; - - serde_wasm_bindgen::from_value(value?).ok()? } pub fn load_tree(stored_tree: StoredTree) -> Tree { @@ -654,6 +353,5 @@ pub fn load_tree(stored_tree: StoredTree) -> Tree { for stored_hash in &stored_tree.hashes { add_hashes.insert(stored_hash.position, stored_hash.height, stored_hash.hash); } - let tree = add_hashes.finish(); - tree + add_hashes.finish() } diff --git a/crates/wasm/src/wasm_planner.rs b/crates/wasm/src/wasm_planner.rs new file mode 100644 index 0000000000..bc48bab6b0 --- /dev/null +++ b/crates/wasm/src/wasm_planner.rs @@ -0,0 +1,188 @@ +use crate::error::WasmResult; +use crate::planner::Planner; +use crate::storage::IndexedDBStorage; +use crate::swap_record::SwapRecord; +use anyhow::Result; +use ark_ff::UniformRand; +use decaf377::Fq; +use penumbra_dex::swap_claim::SwapClaimPlan; +use penumbra_proto::core::chain::v1alpha1::{ChainParameters, FmdParameters}; +use penumbra_proto::core::crypto::v1alpha1::{Address, DenomMetadata, Fee, StateCommitment, Value}; +use penumbra_proto::core::transaction::v1alpha1::{MemoPlaintext, TransactionPlan}; +use penumbra_proto::DomainType; +use rand_core::OsRng; +use serde_wasm_bindgen::Error; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; + +#[wasm_bindgen] +pub struct WasmPlanner { + planner: Planner, + storage: IndexedDBStorage, +} + +#[wasm_bindgen] +impl WasmPlanner { + #[wasm_bindgen(constructor)] + pub async fn new() -> Result { + let planner = WasmPlanner { + planner: Planner::new(OsRng), + storage: IndexedDBStorage::new().await?, + }; + Ok(planner) + } + + /// Add expiry height to plan + /// Arguments: + /// expiry_height: `u64` + #[wasm_bindgen] + pub fn expiry_height(&mut self, expiry_height: JsValue) -> Result<(), Error> { + self.planner + .expiry_height(serde_wasm_bindgen::from_value(expiry_height)?); + Ok(()) + } + + /// Add memo to plan + /// Arguments: + /// memo: `MemoPlaintext` + pub fn memo(&mut self, memo: JsValue) -> WasmResult<()> { + let memo_proto: MemoPlaintext = serde_wasm_bindgen::from_value(memo)?; + let _ = self.planner.memo(memo_proto.try_into()?); + Ok(()) + } + + /// Add fee to plan + /// Arguments: + /// fee: `Fee` + pub fn fee(&mut self, fee: JsValue) -> WasmResult<()> { + let fee_proto: Fee = serde_wasm_bindgen::from_value(fee)?; + self.planner.fee(fee_proto.try_into()?); + + Ok(()) + } + + /// Add output to plan + /// Arguments: + /// value: `Value` + /// address: `Address` + pub fn output(&mut self, value: JsValue, address: JsValue) -> WasmResult<()> { + let value_proto: Value = serde_wasm_bindgen::from_value(value)?; + let address_proto: Address = serde_wasm_bindgen::from_value(address)?; + + self.planner + .output(value_proto.try_into()?, address_proto.try_into()?); + + Ok(()) + } + + /// Add swap claim to plan + /// Arguments: + /// swap_commitment: `StateCommitment` + #[wasm_bindgen] + pub async fn swap_claim(&mut self, swap_commitment: JsValue) -> WasmResult<()> { + let swap_commitment_proto: StateCommitment = + serde_wasm_bindgen::from_value(swap_commitment)?; + + let swap_record: SwapRecord = self + .storage + .get_swap_by_commitment(swap_commitment_proto) + .await? + .expect("Swap record not found") + .try_into()?; + let chain_params_proto: ChainParameters = self + .storage + .get_chain_parameters() + .await? + .expect("Chain params not found"); + + let swap_claim_plan = SwapClaimPlan { + swap_plaintext: swap_record.swap, + position: swap_record.position, + output_data: swap_record.output_data, + epoch_duration: chain_params_proto.epoch_duration, + proof_blinding_r: Fq::rand(&mut OsRng), + proof_blinding_s: Fq::rand(&mut OsRng), + }; + + self.planner.swap_claim(swap_claim_plan); + Ok(()) + } + + /// Add swap to plan + /// Arguments: + /// input_value: `Value` + /// into_denom: `DenomMetadata` + /// swap_claim_fee: `Fee` + /// claim_address: `Address` + pub fn swap( + &mut self, + input_value: JsValue, + into_denom: JsValue, + swap_claim_fee: JsValue, + claim_address: JsValue, + ) -> WasmResult<()> { + let input_value_proto: Value = serde_wasm_bindgen::from_value(input_value)?; + let into_denom_proto: DenomMetadata = serde_wasm_bindgen::from_value(into_denom)?; + let swap_claim_fee_proto: Fee = serde_wasm_bindgen::from_value(swap_claim_fee)?; + let claim_address_proto: Address = serde_wasm_bindgen::from_value(claim_address)?; + + let _ = self.planner.swap( + input_value_proto.try_into()?, + into_denom_proto.try_into()?, + swap_claim_fee_proto.try_into()?, + claim_address_proto.try_into()?, + ); + + Ok(()) + } + + /// Build transaction plan + /// Arguments: + /// self_address: `Address` + /// Returns: `TransactionPlan` + pub async fn plan(&mut self, self_address: JsValue) -> Result { + let self_address_proto: Address = serde_wasm_bindgen::from_value(self_address)?; + let plan = self.plan_inner(self_address_proto).await?; + serde_wasm_bindgen::to_value(&plan) + } +} + +impl WasmPlanner { + async fn plan_inner(&mut self, self_address: Address) -> WasmResult { + let chain_params_proto: ChainParameters = self + .storage + .get_chain_parameters() + .await? + .expect("No found chain params"); + let fmd_params_proto: FmdParameters = self + .storage + .get_fmd_parameters() + .await? + .expect("No found fmd"); + + let mut spendable_notes = Vec::new(); + + let (spendable_requests, _) = self.planner.notes_requests(); + + let idb_storage = IndexedDBStorage::new().await?; + for request in spendable_requests { + let notes = idb_storage.get_notes(request); + spendable_notes.extend(notes.await?); + } + + // Plan the transaction using the gathered information + + let plan: penumbra_transaction::plan::TransactionPlan = + self.planner.plan_with_spendable_and_votable_notes( + &chain_params_proto.try_into()?, + &fmd_params_proto.try_into()?, + spendable_notes, + Vec::new(), + self_address.try_into()?, + )?; + + let plan_proto: TransactionPlan = plan.to_proto(); + + Ok(plan_proto) + } +}