diff --git a/apps/extension/.env b/apps/extension/.env index b7316c8caf..b72914eb8c 100644 --- a/apps/extension/.env +++ b/apps/extension/.env @@ -1,6 +1,6 @@ PRAX=lkpmkhpnhknhmibgnmmhdhgdilepfghe -IDB_VERSION=32 +IDB_VERSION=33 USDC_ASSET_ID="reum7wQmk/owgvGMWMZn/6RFPV24zIKq3W6In/WwZgg=" MINIFRONT_URL=https://app.testnet.penumbra.zone/ PENUMBRA_NODE_PD_URL=https://grpc.testnet.penumbra.zone/ diff --git a/apps/extension/src/state/tx-approval.ts b/apps/extension/src/state/tx-approval.ts index a84ca41208..458058745e 100644 --- a/apps/extension/src/state/tx-approval.ts +++ b/apps/extension/src/state/tx-approval.ts @@ -66,9 +66,9 @@ export const createTxApprovalSlice = (): SliceCreator => (set, const getMetadata = async (assetId: AssetId) => { try { const { denomMetadata } = await viewClient.assetMetadataById({ assetId }); - return denomMetadata ?? new Metadata(); + return denomMetadata ?? new Metadata({ penumbraAssetId: assetId }); } catch { - return new Metadata(); + return new Metadata({ penumbraAssetId: assetId }); } }; diff --git a/packages/storage/src/indexed-db/index.ts b/packages/storage/src/indexed-db/index.ts index 39f766d512..30b8a8d374 100644 --- a/packages/storage/src/indexed-db/index.ts +++ b/packages/storage/src/indexed-db/index.ts @@ -118,7 +118,10 @@ export class IndexedDb implements IndexedDbInterface { const instance = new this(db, new IbdUpdater(db), constants, chainId); await instance.saveLocalAssetsMetadata(); // Pre-load asset metadata - await instance.addEpoch(0n); // Create first epoch + + const existing0thEpoch = await instance.getEpochByHeight(0n); + if (!existing0thEpoch) await instance.addEpoch(0n); // Create first epoch + return instance; } close(): void { diff --git a/packages/types/src/indexed-db.ts b/packages/types/src/indexed-db.ts index f5952c0615..cb38adcbb2 100644 --- a/packages/types/src/indexed-db.ts +++ b/packages/types/src/indexed-db.ts @@ -219,4 +219,5 @@ export const IDB_TABLES: Tables = { prices: 'PRICES', validator_infos: 'VALIDATOR_INFOS', transactions: 'TRANSACTIONS', + full_sync_height: 'FULL_SYNC_HEIGHT', }; diff --git a/packages/wasm/crate/Cargo.lock b/packages/wasm/crate/Cargo.lock index 1e732cfe36..2c3fd396a3 100644 --- a/packages/wasm/crate/Cargo.lock +++ b/packages/wasm/crate/Cargo.lock @@ -2750,6 +2750,7 @@ dependencies = [ "penumbra-transaction", "prost", "rand_core", + "regex", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/packages/wasm/crate/Cargo.toml b/packages/wasm/crate/Cargo.toml index 37c0afedf6..968a1ee383 100644 --- a/packages/wasm/crate/Cargo.toml +++ b/packages/wasm/crate/Cargo.toml @@ -38,6 +38,7 @@ hex = "0.4.3" indexed_db_futures = "0.4.1" prost = "0.12.3" rand_core = { version = "0.6.4", features = ["getrandom"] } +regex = { version = "1.8.1" } serde = { version = "1.0.197", features = ["derive"] } serde-wasm-bindgen = "0.6.5" thiserror = "1.0" diff --git a/packages/wasm/crate/src/error.rs b/packages/wasm/crate/src/error.rs index 247e663918..7f6408f1b3 100644 --- a/packages/wasm/crate/src/error.rs +++ b/packages/wasm/crate/src/error.rs @@ -42,6 +42,9 @@ pub enum WasmError { #[error("{0}")] Wasm(#[from] serde_wasm_bindgen::Error), + + #[error("{0}")] + RegexError(#[from] regex::Error), } impl From for serde_wasm_bindgen::Error { diff --git a/packages/wasm/crate/src/lib.rs b/packages/wasm/crate/src/lib.rs index f4e6714e84..700ede3cce 100644 --- a/packages/wasm/crate/src/lib.rs +++ b/packages/wasm/crate/src/lib.rs @@ -8,6 +8,7 @@ pub mod build; pub mod dex; pub mod error; pub mod keys; +pub mod metadata; pub mod note_record; pub mod planner; pub mod storage; diff --git a/packages/wasm/crate/src/metadata.rs b/packages/wasm/crate/src/metadata.rs new file mode 100644 index 0000000000..9db18f59a4 --- /dev/null +++ b/packages/wasm/crate/src/metadata.rs @@ -0,0 +1,63 @@ +use anyhow::anyhow; +use penumbra_asset::asset::Metadata as MetadataDomainType; +use penumbra_proto::{core::asset::v1::Metadata, DomainType}; +use regex::Regex; +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::{error::WasmResult, utils}; + +pub static UNBONDING_TOKEN_REGEX: &str = "^uunbonding_(?Pstart_at_(?P[0-9]+)_(?Ppenumbravalid1(?P[a-zA-HJ-NP-Z0-9]+)))$"; +pub static DELEGATION_TOKEN_REGEX: &str = + "^udelegation_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+)$"; +pub static SHORTENED_ID_LENGTH: usize = 8; + +#[wasm_bindgen] +pub fn customize_symbol(metadata_bytes: &[u8]) -> WasmResult> { + utils::set_panic_hook(); + + let metadata_domain_type = MetadataDomainType::decode(metadata_bytes)?; + let metadata_proto = customize_symbol_inner(metadata_domain_type.to_proto())?; + + match MetadataDomainType::try_from(metadata_proto) { + Ok(customized_metadata_domain_type) => Ok(customized_metadata_domain_type.encode_to_vec()), + Err(error) => Err(error.into()), + } +} + +pub fn customize_symbol_inner(metadata: Metadata) -> WasmResult { + let unbonding_re = Regex::new(UNBONDING_TOKEN_REGEX)?; + let delegation_re = Regex::new(DELEGATION_TOKEN_REGEX)?; + + if let Some(captures) = unbonding_re.captures(&metadata.base) { + let shortened_id = shorten_id(&captures)?; + let start_match = captures + .name("start") + .ok_or_else(|| anyhow!(" not matched in unbonding token regex"))? + .as_str(); + + return Ok(Metadata { + symbol: format!("unbondUMat{start_match}({shortened_id}...)"), + ..metadata + }); + } else if let Some(captures) = delegation_re.captures(&metadata.base) { + let shortened_id = shorten_id(&captures)?; + + return Ok(Metadata { + symbol: format!("delUM({shortened_id}...)"), + ..metadata + }); + } + + Ok(metadata) +} + +fn shorten_id(captures: ®ex::Captures) -> WasmResult { + let id_match = captures + .name("id") + .ok_or_else(|| anyhow!(" not matched in staking token regex"))?; + Ok(id_match + .as_str() + .chars() + .take(SHORTENED_ID_LENGTH) + .collect()) +} diff --git a/packages/wasm/crate/src/planner.rs b/packages/wasm/crate/src/planner.rs index 3bac8fcbe2..f310eefde6 100644 --- a/packages/wasm/crate/src/planner.rs +++ b/packages/wasm/crate/src/planner.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use anyhow::anyhow; use ark_ff::UniformRand; use decaf377::{Fq, Fr}; +use penumbra_asset::asset::Metadata; use penumbra_asset::{asset, Balance, Value}; use penumbra_dex::swap_claim::SwapClaimPlan; use penumbra_dex::{ @@ -22,7 +23,7 @@ use penumbra_proto::DomainType; use penumbra_sct::params::SctParameters; use penumbra_shielded_pool::{fmd, OutputPlan, SpendPlan}; use penumbra_stake::rate::RateData; -use penumbra_stake::{IdentityKey, Penalty, UndelegateClaimPlan}; +use penumbra_stake::{IdentityKey, Penalty, Undelegate, UndelegateClaimPlan}; use penumbra_transaction::gas::GasCost; use penumbra_transaction::memo::MemoPlaintext; use penumbra_transaction::{plan::MemoPlan, ActionPlan, TransactionParameters, TransactionPlan}; @@ -31,6 +32,7 @@ use rand_core::OsRng; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; +use crate::metadata::customize_symbol_inner; use crate::note_record::SpendableNoteRecord; use crate::storage::IndexedDBStorage; use crate::utils; @@ -319,11 +321,12 @@ pub async fn plan_transaction( let rate_data: RateData = rate_data .ok_or_else(|| anyhow!("missing rate data in undelegation"))? .try_into()?; - actions.push( - rate_data - .build_undelegate(epoch.into(), value.amount) - .into(), - ); + + let undelegate = rate_data.build_undelegate(epoch.into(), value.amount); + + save_unbonding_token_metadata_if_needed(&undelegate, &storage).await?; + + actions.push(undelegate.into()); } for tpr::UndelegateClaim { @@ -480,3 +483,25 @@ pub async fn plan_transaction( Ok(serde_wasm_bindgen::to_value(&plan)?) } + +/// When planning an undelegate action, there may not be metadata yet in the +/// IndexedDB database for the unbonding token that the transaction will output. +/// That's because unbonding tokens are tied to a specific height. If unbonding +/// tokens for a given validator and a given height don't exist yet, we'll +/// generate them here and save them to the database, so that they can render +/// correctly in the transaction approval dialog. +async fn save_unbonding_token_metadata_if_needed( + undelegate: &Undelegate, + storage: &IndexedDBStorage, +) -> WasmResult<()> { + let metadata = undelegate.unbonding_token().denom(); + + if storage.get_asset(&metadata.id()).await?.is_none() { + let metadata_proto = metadata.to_proto(); + let customized_metadata_proto = customize_symbol_inner(metadata_proto)?; + let customized_metadata = Metadata::try_from(customized_metadata_proto)?; + storage.add_asset(&customized_metadata).await + } else { + Ok(()) + } +} diff --git a/packages/wasm/crate/src/storage.rs b/packages/wasm/crate/src/storage.rs index 205e0b4f6d..fa8f2ad3b3 100644 --- a/packages/wasm/crate/src/storage.rs +++ b/packages/wasm/crate/src/storage.rs @@ -41,6 +41,7 @@ pub struct Tables { pub gas_prices: String, pub epochs: String, pub transactions: String, + pub full_sync_height: String, } pub struct IndexedDBStorage { @@ -176,6 +177,31 @@ impl IndexedDBStorage { .transpose()?) } + pub async fn add_asset(&self, metadata: &Metadata) -> WasmResult<()> { + let tx = self + .db + .transaction_on_one_with_mode(&self.constants.tables.assets, Readwrite)?; + let store = tx.object_store(&self.constants.tables.assets)?; + let metadata_js = serde_wasm_bindgen::to_value(&metadata.to_proto())?; + + store.put_val_owned(&metadata_js)?; + + Ok(()) + } + + pub async fn get_full_sync_height(&self) -> WasmResult> { + let tx = self + .db + .transaction_on_one(&self.constants.tables.full_sync_height)?; + let store = tx.object_store(&self.constants.tables.full_sync_height)?; + + Ok(store + .get_owned("height")? + .await? + .map(serde_wasm_bindgen::from_value) + .transpose()?) + } + pub async fn get_note( &self, commitment: ¬e::StateCommitment, diff --git a/packages/wasm/crate/tests/build.rs b/packages/wasm/crate/tests/build.rs index 2059abf964..2f2666ce5c 100644 --- a/packages/wasm/crate/tests/build.rs +++ b/packages/wasm/crate/tests/build.rs @@ -86,6 +86,7 @@ mod tests { gas_prices: String, epochs: String, transactions: String, + full_sync_height: String, } // Define `IndexDB` table parameters and constants. @@ -99,6 +100,7 @@ mod tests { gas_prices: "GAS_PRICES".to_string(), epochs: "EPOCHS".to_string(), transactions: "TRANSACTIONS".to_string(), + full_sync_height: "FULL_SYNC_HEIGHT".to_string(), }; let constants: IndexedDbConstants = IndexedDbConstants {